sayren 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.mjs +416 -9
  2. package/package.json +2 -2
package/dist/index.mjs CHANGED
@@ -5294,6 +5294,79 @@ const mcp_rule_loader_first_why = ((inputs = {}, options = {}) => {
5294
5294
  return ko_mcp_rule_loader_first_why(inputs);
5295
5295
  });
5296
5296
 
5297
+ //#endregion
5298
+ //#region ../i18n/src/paraglide/messages/mcp_rule_local_import_exists_example.js
5299
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5300
+ /** @typedef {{}} Mcp_Rule_Local_Import_Exists_ExampleInputs */
5301
+ const ko_mcp_rule_local_import_exists_example = () => {
5302
+ return `// src/i18n/index.ts — 작업 트리에 messages/ko.json이 있어야 한다
5303
+ import ko from "../../messages/ko.json";`;
5304
+ };
5305
+ /**
5306
+
5307
+ * @param {Mcp_Rule_Local_Import_Exists_ExampleInputs} inputs
5308
+ * @param {{ locale?: "ko" }} options
5309
+ * @returns {LocalizedString}
5310
+ */
5311
+ const mcp_rule_local_import_exists_example = ((inputs = {}, options = {}) => {
5312
+ experimentalStaticLocale ?? options.locale ?? getLocale();
5313
+ return ko_mcp_rule_local_import_exists_example(inputs);
5314
+ });
5315
+
5316
+ //#endregion
5317
+ //#region ../i18n/src/paraglide/messages/mcp_rule_local_import_exists_fix.js
5318
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5319
+ /** @typedef {{}} Mcp_Rule_Local_Import_Exists_FixInputs */
5320
+ const ko_mcp_rule_local_import_exists_fix = () => {
5321
+ return `빠진 파일을 템플릿 원본(로컬 \`get_template_file\`, 또는 \`npx @sayren/mcp create\`로 받은 사본)에서 가져와 같은 경로에 둔다. 호스팅 사이트는 \`write_files\`(\`expectedHash: null\`)나 \`sayren deploy\`로 올린다. 쓰지 않는 import면 지운다. 템플릿의 화면 문구는 \`messages/ko.json\`이다`;
5322
+ };
5323
+ /**
5324
+
5325
+ * @param {Mcp_Rule_Local_Import_Exists_FixInputs} inputs
5326
+ * @param {{ locale?: "ko" }} options
5327
+ * @returns {LocalizedString}
5328
+ */
5329
+ const mcp_rule_local_import_exists_fix = ((inputs = {}, options = {}) => {
5330
+ experimentalStaticLocale ?? options.locale ?? getLocale();
5331
+ return ko_mcp_rule_local_import_exists_fix(inputs);
5332
+ });
5333
+
5334
+ //#endregion
5335
+ //#region ../i18n/src/paraglide/messages/mcp_rule_local_import_exists_title.js
5336
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5337
+ /** @typedef {{}} Mcp_Rule_Local_Import_Exists_TitleInputs */
5338
+ const ko_mcp_rule_local_import_exists_title = () => {
5339
+ return `상대 경로로 import한 파일은 작업 트리에 있어야 한다`;
5340
+ };
5341
+ /**
5342
+
5343
+ * @param {Mcp_Rule_Local_Import_Exists_TitleInputs} inputs
5344
+ * @param {{ locale?: "ko" }} options
5345
+ * @returns {LocalizedString}
5346
+ */
5347
+ const mcp_rule_local_import_exists_title = ((inputs = {}, options = {}) => {
5348
+ experimentalStaticLocale ?? options.locale ?? getLocale();
5349
+ return ko_mcp_rule_local_import_exists_title(inputs);
5350
+ });
5351
+
5352
+ //#endregion
5353
+ //#region ../i18n/src/paraglide/messages/mcp_rule_local_import_exists_why.js
5354
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5355
+ /** @typedef {{}} Mcp_Rule_Local_Import_Exists_WhyInputs */
5356
+ const ko_mcp_rule_local_import_exists_why = () => {
5357
+ return `규칙 검사는 코드 문자열만 보지만 타입 검사와 빌드는 import한 파일을 실제로 읽는다. 대상 파일이 없으면 \`Cannot find module\`(TS2307)로 검사와 버전 빌드가 실패한다`;
5358
+ };
5359
+ /**
5360
+
5361
+ * @param {Mcp_Rule_Local_Import_Exists_WhyInputs} inputs
5362
+ * @param {{ locale?: "ko" }} options
5363
+ * @returns {LocalizedString}
5364
+ */
5365
+ const mcp_rule_local_import_exists_why = ((inputs = {}, options = {}) => {
5366
+ experimentalStaticLocale ?? options.locale ?? getLocale();
5367
+ return ko_mcp_rule_local_import_exists_why(inputs);
5368
+ });
5369
+
5297
5370
  //#endregion
5298
5371
  //#region ../i18n/src/paraglide/messages/mcp_rule_no_module_token_example.js
5299
5372
  /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
@@ -5880,6 +5953,85 @@ const mcp_rule_server_only_env_why = ((inputs = {}, options = {}) => {
5880
5953
  return ko_mcp_rule_server_only_env_why(inputs);
5881
5954
  });
5882
5955
 
5956
+ //#endregion
5957
+ //#region ../i18n/src/paraglide/messages/mcp_rule_server_only_import_example.js
5958
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5959
+ /** @typedef {{}} Mcp_Rule_Server_Only_Import_ExampleInputs */
5960
+ const ko_mcp_rule_server_only_import_example = () => {
5961
+ return `// src/lib/my-orders.ts — 브라우저도 가져가는 파일
5962
+ import { apiFor } from "./api.server";
5963
+ import { readToken } from "./session.server";
5964
+
5965
+ // handler 안에서만 부른다. 일반 함수(export function)로 두면 브라우저 번들에 남는다
5966
+ export const listMyOrders = createServerFn({ method: "GET" }).handler(() =>
5967
+ apiFor({ accessToken: readToken() }).orders.list(),
5968
+ );`;
5969
+ };
5970
+ /**
5971
+
5972
+ * @param {Mcp_Rule_Server_Only_Import_ExampleInputs} inputs
5973
+ * @param {{ locale?: "ko" }} options
5974
+ * @returns {LocalizedString}
5975
+ */
5976
+ const mcp_rule_server_only_import_example = ((inputs = {}, options = {}) => {
5977
+ experimentalStaticLocale ?? options.locale ?? getLocale();
5978
+ return ko_mcp_rule_server_only_import_example(inputs);
5979
+ });
5980
+
5981
+ //#endregion
5982
+ //#region ../i18n/src/paraglide/messages/mcp_rule_server_only_import_fix.js
5983
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5984
+ /** @typedef {{}} Mcp_Rule_Server_Only_Import_FixInputs */
5985
+ const ko_mcp_rule_server_only_import_fix = () => {
5986
+ return `서버 API를 부르는 함수는 \`*.server.ts\` 파일로 옮기거나 \`createServerFn().handler(…)\`로 감싼다. 브라우저가 함께 가져가는 파일(컴포넌트·\`src/lib/*.ts\`)은 서버 함수만 내보내고, \`*.server.ts\`의 값은 handler 안에서만 부른다. 서버 전용 모듈을 다시 내보내지 않는다`;
5987
+ };
5988
+ /**
5989
+
5990
+ * @param {Mcp_Rule_Server_Only_Import_FixInputs} inputs
5991
+ * @param {{ locale?: "ko" }} options
5992
+ * @returns {LocalizedString}
5993
+ */
5994
+ const mcp_rule_server_only_import_fix = ((inputs = {}, options = {}) => {
5995
+ experimentalStaticLocale ?? options.locale ?? getLocale();
5996
+ return ko_mcp_rule_server_only_import_fix(inputs);
5997
+ });
5998
+
5999
+ //#endregion
6000
+ //#region ../i18n/src/paraglide/messages/mcp_rule_server_only_import_title.js
6001
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
6002
+ /** @typedef {{}} Mcp_Rule_Server_Only_Import_TitleInputs */
6003
+ const ko_mcp_rule_server_only_import_title = () => {
6004
+ return `서버 전용 모듈(\`*.server.ts\`)에서 가져온 것은 서버 함수 안에서만 쓴다`;
6005
+ };
6006
+ /**
6007
+
6008
+ * @param {Mcp_Rule_Server_Only_Import_TitleInputs} inputs
6009
+ * @param {{ locale?: "ko" }} options
6010
+ * @returns {LocalizedString}
6011
+ */
6012
+ const mcp_rule_server_only_import_title = ((inputs = {}, options = {}) => {
6013
+ experimentalStaticLocale ?? options.locale ?? getLocale();
6014
+ return ko_mcp_rule_server_only_import_title(inputs);
6015
+ });
6016
+
6017
+ //#endregion
6018
+ //#region ../i18n/src/paraglide/messages/mcp_rule_server_only_import_why.js
6019
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
6020
+ /** @typedef {{}} Mcp_Rule_Server_Only_Import_WhyInputs */
6021
+ const ko_mcp_rule_server_only_import_why = () => {
6022
+ return `TanStack Start는 서버 함수 handler만 브라우저 번들에서 걷어낸다. 그 밖에서 \`*.server.ts\`(\`api.server\` 등)의 값을 쓰면 import가 브라우저 번들에 남고, import 보호가 버전 빌드(\`vite build\`)를 실패시킨다. 개발 서버와 타입 검사는 이것을 잡지 않는다`;
6023
+ };
6024
+ /**
6025
+
6026
+ * @param {Mcp_Rule_Server_Only_Import_WhyInputs} inputs
6027
+ * @param {{ locale?: "ko" }} options
6028
+ * @returns {LocalizedString}
6029
+ */
6030
+ const mcp_rule_server_only_import_why = ((inputs = {}, options = {}) => {
6031
+ experimentalStaticLocale ?? options.locale ?? getLocale();
6032
+ return ko_mcp_rule_server_only_import_why(inputs);
6033
+ });
6034
+
5883
6035
  //#endregion
5884
6036
  //#region ../i18n/src/paraglide/messages/mcp_rule_tenant_header_fix.js
5885
6037
  /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
@@ -6312,6 +6464,24 @@ const mcp_verify_loader_first_query = ((inputs = {}, options = {}) => {
6312
6464
  return ko_mcp_verify_loader_first_query(inputs);
6313
6465
  });
6314
6466
 
6467
+ //#endregion
6468
+ //#region ../i18n/src/paraglide/messages/mcp_verify_local_import_missing.js
6469
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
6470
+ /** @typedef {{ specifier: NonNullable<unknown>, path: NonNullable<unknown> }} Mcp_Verify_Local_Import_MissingInputs */
6471
+ const ko_mcp_verify_local_import_missing = (i) => {
6472
+ return `\`${i?.specifier}\`를 import하지만 작업 트리에 \`${i?.path}\`가 없다`;
6473
+ };
6474
+ /**
6475
+
6476
+ * @param {Mcp_Verify_Local_Import_MissingInputs} inputs
6477
+ * @param {{ locale?: "ko" }} options
6478
+ * @returns {LocalizedString}
6479
+ */
6480
+ const mcp_verify_local_import_missing = ((inputs, options = {}) => {
6481
+ experimentalStaticLocale ?? options.locale ?? getLocale();
6482
+ return ko_mcp_verify_local_import_missing(inputs);
6483
+ });
6484
+
6315
6485
  //#endregion
6316
6486
  //#region ../i18n/src/paraglide/messages/mcp_verify_no_module_token.js
6317
6487
  /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
@@ -6474,6 +6644,42 @@ const mcp_verify_server_only_env = ((inputs = {}, options = {}) => {
6474
6644
  return ko_mcp_verify_server_only_env(inputs);
6475
6645
  });
6476
6646
 
6647
+ //#endregion
6648
+ //#region ../i18n/src/paraglide/messages/mcp_verify_server_only_import.js
6649
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
6650
+ /** @typedef {{ specifier: NonNullable<unknown>, name: NonNullable<unknown> }} Mcp_Verify_Server_Only_ImportInputs */
6651
+ const ko_mcp_verify_server_only_import = (i) => {
6652
+ return `서버 전용 모듈 \`${i?.specifier}\`의 \`${i?.name}\`을 서버 함수(handler) 밖에서 쓴다. 브라우저 번들에 남아 버전 빌드가 import 보호로 실패한다`;
6653
+ };
6654
+ /**
6655
+
6656
+ * @param {Mcp_Verify_Server_Only_ImportInputs} inputs
6657
+ * @param {{ locale?: "ko" }} options
6658
+ * @returns {LocalizedString}
6659
+ */
6660
+ const mcp_verify_server_only_import = ((inputs, options = {}) => {
6661
+ experimentalStaticLocale ?? options.locale ?? getLocale();
6662
+ return ko_mcp_verify_server_only_import(inputs);
6663
+ });
6664
+
6665
+ //#endregion
6666
+ //#region ../i18n/src/paraglide/messages/mcp_verify_server_only_import_reexport.js
6667
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
6668
+ /** @typedef {{ specifier: NonNullable<unknown> }} Mcp_Verify_Server_Only_Import_ReexportInputs */
6669
+ const ko_mcp_verify_server_only_import_reexport = (i) => {
6670
+ return `서버 전용 모듈 \`${i?.specifier}\`를 부수 효과 import나 다시 내보내기로 가져온다. 브라우저 번들에 남아 버전 빌드가 import 보호로 실패한다`;
6671
+ };
6672
+ /**
6673
+
6674
+ * @param {Mcp_Verify_Server_Only_Import_ReexportInputs} inputs
6675
+ * @param {{ locale?: "ko" }} options
6676
+ * @returns {LocalizedString}
6677
+ */
6678
+ const mcp_verify_server_only_import_reexport = ((inputs, options = {}) => {
6679
+ experimentalStaticLocale ?? options.locale ?? getLocale();
6680
+ return ko_mcp_verify_server_only_import_reexport(inputs);
6681
+ });
6682
+
6477
6683
  //#endregion
6478
6684
  //#region ../i18n/src/paraglide/messages/mcp_verify_tenant_header.js
6479
6685
  /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
@@ -6561,7 +6767,7 @@ function takeLangFlag(argv) {
6561
6767
 
6562
6768
  //#endregion
6563
6769
  //#region package.json
6564
- var version = "0.3.2";
6770
+ var version = "0.3.4";
6565
6771
 
6566
6772
  //#endregion
6567
6773
  //#region src/client.ts
@@ -7783,6 +7989,23 @@ const RULES = [
7783
7989
  doc: "/guides/storefront-sdk",
7784
7990
  templateFile: "src/lib/ordering.ts",
7785
7991
  severity: "warning"
7992
+ }),
7993
+ defineRule({
7994
+ id: "server-only-import",
7995
+ title: mcp_rule_server_only_import_title,
7996
+ why: mcp_rule_server_only_import_why,
7997
+ fix: mcp_rule_server_only_import_fix,
7998
+ example: mcp_rule_server_only_import_example,
7999
+ doc: "/guides/mcp-customize",
8000
+ templateFile: "src/lib/api.server.ts"
8001
+ }),
8002
+ defineRule({
8003
+ id: "local-import-exists",
8004
+ title: mcp_rule_local_import_exists_title,
8005
+ why: mcp_rule_local_import_exists_why,
8006
+ fix: mcp_rule_local_import_exists_fix,
8007
+ example: mcp_rule_local_import_exists_example,
8008
+ doc: "/guides/mcp-customize"
7786
8009
  })
7787
8010
  ];
7788
8011
  /**
@@ -7816,7 +8039,7 @@ const CUSTOM_INPUT_LEAKS = [
7816
8039
  * 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
7817
8040
  * 여기 있는 것들은 실제로 반복해서 깨지는 것들이다.
7818
8041
  */
7819
- function verifySources(files) {
8042
+ function verifySources(files, options = {}) {
7820
8043
  const findings = [];
7821
8044
  const cleaned = files.map((file) => ({
7822
8045
  path: file.path,
@@ -7889,8 +8112,175 @@ function verifySources(files) {
7889
8112
  const clientQuery = matches(/\buse(Suspense)?Query\(/);
7890
8113
  if (clientQuery && !has("ensureQueryData(") && !has("prefetchQuery(")) inFile("loader-first", clientQuery, mcp_verify_loader_first_query(), /\buse(Suspense)?Query\(/);
7891
8114
  if (has("createStorefrontClient(") && !has("loader")) inProject("loader-first", mcp_verify_loader_first_missing());
8115
+ findings.push(...serverOnlyImports(cleaned));
8116
+ if (options.paths) findings.push(...missingLocalImports(cleaned, options.paths));
7892
8117
  return findings;
7893
8118
  }
8119
+ /** 상대 경로 import·export·동적 import의 대상 — `from "./x"`, `import "./x"`, `import("./x")` */
8120
+ const RELATIVE_IMPORT = /(?:\bfrom\s*|\bimport\s*\(?\s*)(["'])(\.{1,2}\/[^"'\n]+)\1/g;
8121
+ /** 확장자 없이 쓴 import가 가리킬 수 있는 파일 */
8122
+ const RESOLVE_SUFFIXES = [
8123
+ ".ts",
8124
+ ".tsx",
8125
+ ".js",
8126
+ ".jsx",
8127
+ ".mjs",
8128
+ ".json",
8129
+ "/index.ts",
8130
+ "/index.tsx",
8131
+ "/index.js",
8132
+ "/index.jsx"
8133
+ ];
8134
+ /** `from` 파일 기준으로 상대 경로를 푼다. 작업 트리 루트 밖으로 나가면 null(판정하지 않는다) */
8135
+ function resolveRelative(from, specifier) {
8136
+ const segments = from.split("/").slice(0, -1);
8137
+ for (const segment of specifier.split("/")) {
8138
+ if (segment === "" || segment === ".") continue;
8139
+ if (segment === "..") {
8140
+ if (!segments.length) return null;
8141
+ segments.pop();
8142
+ } else segments.push(segment);
8143
+ }
8144
+ return segments.join("/");
8145
+ }
8146
+ /**
8147
+ * 상대 경로로 import한 파일이 작업 트리에 있는가(이슈 #78) — 규칙 검사는 소스 문자열만 보므로 빠진 파일(예: 템플릿의
8148
+ * `messages/ko.json`)을 타입 검사·빌드 전에는 알 수 없었다. `paths`는 검사 대상 트리의 모든 파일 경로다(소스 아닌 파일 포함).
8149
+ * vite 쿼리(`?url`·`?raw`)는 떼고, `.js`로 쓴 TypeScript import(`./a.js` → `a.ts`)도 받는다.
8150
+ */
8151
+ function missingLocalImports(files, paths) {
8152
+ const tree = new Set(paths);
8153
+ const out = [];
8154
+ for (const file of files) for (const match of file.content.matchAll(RELATIVE_IMPORT)) {
8155
+ const specifier = match[2];
8156
+ const target = resolveRelative(file.path, specifier.replace(/[?#].*$/, ""));
8157
+ if (target === null || target === "") continue;
8158
+ const candidates = [target, ...RESOLVE_SUFFIXES.map((suffix) => `${target}${suffix}`)];
8159
+ const js = /\.(m?js|jsx)$/.exec(target);
8160
+ if (js) {
8161
+ const stem = target.slice(0, -js[0].length);
8162
+ candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.mts`);
8163
+ }
8164
+ if (candidates.some((candidate) => tree.has(candidate))) continue;
8165
+ out.push({
8166
+ ruleId: "local-import-exists",
8167
+ file: file.path,
8168
+ line: file.content.slice(0, match.index).split("\n").length,
8169
+ detail: mcp_verify_local_import_missing({
8170
+ specifier,
8171
+ path: target
8172
+ })
8173
+ });
8174
+ }
8175
+ return out;
8176
+ }
8177
+ /**
8178
+ * 서버 전용 모듈 — TanStack Start import 보호의 기본 판정과 같다(클라이언트 환경에서 `*.server.*` 파일과
8179
+ * `@tanstack/{react,solid,vue}-start/server`를 막는다). 표시 모듈 `…/server-only`는 막는 대상이 아니다.
8180
+ */
8181
+ const SERVER_ONLY_SPECIFIER = /^(?:@tanstack\/(?:react|solid|vue)-start\/server|\.{1,2}\/(?:[^"'\n]*\/)?[^/"'\n]+\.server(?:\.[cm]?[jt]sx?)?)$/;
8182
+ /** 그 자체가 브라우저로 가지 않아 import 보호가 보지 않는 파일 — `*.server.*`와 테스트 */
8183
+ const SERVER_ONLY_IMPORTER = /\.server\.[cm]?[jt]sx?$|\.(test|spec)\.[cm]?[jt]sx?$/;
8184
+ /** 스스로 서버 전용임을 밝힌 모듈 — 브라우저가 가져가면 import 보호가 그 모듈을 막으므로 여기서는 보지 않는다 */
8185
+ const SERVER_ONLY_MARKER = /\bimport\s*["']@tanstack\/(?:react|solid|vue)-start\/server-only["']/;
8186
+ /** 정적 import 문(여러 줄 포함). 1: `type` 2: 가져오는 이름들 3: 모듈 */
8187
+ const STATIC_IMPORT = /\bimport\s+(type\s+)?([^;"'`]*?)\s*\bfrom\s*["']([^"'\n]+)["']\s*;?/g;
8188
+ /** 부수 효과 import·다시 내보내기 — 서버 전용 모듈이면 그대로 브라우저 번들에 남는다 */
8189
+ const SIDE_EFFECT_OR_REEXPORT = /\bimport\s*["']([^"'\n]+)["']|\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[\w$]+)?|\{[^}]*\})\s*from\s*["']([^"'\n]+)["']/g;
8190
+ /**
8191
+ * 서버에서만 도는 자리 — Start 컴파일러가 클라이언트 번들에서 걷어내는 인자다. `createServerFn().handler(…)`,
8192
+ * `createMiddleware().server(…)`·`createIsomorphicFn().server(…)`, `createServerOnlyFn(…)`, 서버 라우트
8193
+ * `createFileRoute(…)({ server: { handlers } })`의 `server` 객체. 일치의 마지막 글자가 여는 괄호다
8194
+ */
8195
+ const SERVER_CONTEXT = /\.(?:handler|server)\s*\(|\bcreateServerOnlyFn\s*\(|\bserver\s*:\s*\{/g;
8196
+ /** import 문이 가져오는 값 이름 — `type` 표시가 붙은 이름은 빼고, `a as b`는 `b`다 */
8197
+ function importedBindings(clause) {
8198
+ const names = [];
8199
+ const named = /\{([^}]*)\}/.exec(clause);
8200
+ for (const part of named?.[1]?.split(",") ?? []) {
8201
+ const item = part.trim();
8202
+ if (!item || /^type\s/.test(item)) continue;
8203
+ const alias = /\bas\s+([\w$]+)$/.exec(item);
8204
+ names.push(alias ? alias[1] : item.split(/\s+/)[0]);
8205
+ }
8206
+ const rest = clause.replace(/\{[^}]*\}/, "");
8207
+ const namespace = /\*\s*as\s+([\w$]+)/.exec(rest);
8208
+ if (namespace) names.push(namespace[1]);
8209
+ const defaultName = /^\s*([\w$]+)\s*(?:,|$)/.exec(rest.replace(/\*\s*as\s+[\w$]+/, ""));
8210
+ if (defaultName) names.push(defaultName[1]);
8211
+ return names.filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
8212
+ }
8213
+ /** `open` 위치의 여는 괄호와 짝이 맞는 닫는 괄호 다음 위치. 문자열·템플릿 리터럴 안의 괄호는 세지 않는다 */
8214
+ function closingBracket(source, open) {
8215
+ let depth = 0;
8216
+ let quote = null;
8217
+ for (let i = open; i < source.length; i++) {
8218
+ const ch = source[i];
8219
+ if (quote) {
8220
+ if (ch === "\\") i++;
8221
+ else if (ch === quote) quote = null;
8222
+ continue;
8223
+ }
8224
+ if (ch === "\"" || ch === "'" || ch === "`") quote = ch;
8225
+ else if (ch === "(" || ch === "{" || ch === "[") depth++;
8226
+ else if ((ch === ")" || ch === "}" || ch === "]") && --depth === 0) return i + 1;
8227
+ }
8228
+ return source.length;
8229
+ }
8230
+ /** 줄 수를 지키며 구간을 공백으로 지운다 — 남은 코드의 위치가 원본 줄 번호와 맞아야 한다 */
8231
+ const blank = (text) => text.replace(/[^\n]/g, " ");
8232
+ /**
8233
+ * 브라우저로 갈 수 있는 모듈이 서버 전용 모듈을 쓰는가(이슈 #88). TanStack Start는 서버 함수 handler처럼 서버에서만
8234
+ * 도는 인자를 클라이언트 번들에서 걷어내고, 그 뒤에도 `*.server.*` import가 남으면 import 보호로 빌드를 실패시킨다.
8235
+ * 그래서 import 자체가 아니라 **서버 자리 밖에서 가져온 이름을 쓰는 것**을 위반으로 본다. 템플릿은 라우트·`lib`에서
8236
+ * `apiFor`를 import하고 `createServerFn().handler(…)`·서버 라우트 `server` 안에서만 부른다. 서버 자리 밖의 일반 함수(예: 서버 API를 부르는
8237
+ * `export function wishedOf()`)는 브라우저 번들에 남으므로 위반이다 — 그 함수를 `*.server.ts`로 옮기거나 서버 함수로 감싼다.
8238
+ * 정적 검사라 서버 자리에서만 부르는 지역 함수까지 따라가지는 않는다(보수적으로 위반으로 본다).
8239
+ */
8240
+ function serverOnlyImports(files) {
8241
+ const out = [];
8242
+ for (const file of files) {
8243
+ if (!CLIENT_FILE.test(file.path) || SERVER_ONLY_IMPORTER.test(file.path)) continue;
8244
+ if (SERVER_ONLY_MARKER.test(file.content)) continue;
8245
+ const lineAt = (index) => file.content.slice(0, index).split("\n").length;
8246
+ for (const match of file.content.matchAll(SIDE_EFFECT_OR_REEXPORT)) {
8247
+ const specifier = match[1] ?? match[2];
8248
+ if (!SERVER_ONLY_SPECIFIER.test(specifier)) continue;
8249
+ out.push({
8250
+ ruleId: "server-only-import",
8251
+ file: file.path,
8252
+ line: lineAt(match.index),
8253
+ detail: mcp_verify_server_only_import_reexport({ specifier })
8254
+ });
8255
+ }
8256
+ const bindings = /* @__PURE__ */ new Map();
8257
+ let body = file.content.replace(STATIC_IMPORT, (statement, typeOnly, clause, specifier) => {
8258
+ if (!typeOnly && SERVER_ONLY_SPECIFIER.test(specifier)) for (const name of importedBindings(clause)) bindings.set(name, specifier);
8259
+ return blank(statement);
8260
+ });
8261
+ if (bindings.size === 0) continue;
8262
+ for (const context of [...body.matchAll(SERVER_CONTEXT)]) {
8263
+ const open = context.index + context[0].length - 1;
8264
+ if (body[open] !== "(" && body[open] !== "{") continue;
8265
+ const end = closingBracket(body, open);
8266
+ body = body.slice(0, open) + blank(body.slice(open, end)) + body.slice(end);
8267
+ }
8268
+ for (const [name, specifier] of bindings) {
8269
+ const use = (/* @__PURE__ */ new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}(?![\\w$])`)).exec(body);
8270
+ if (!use) continue;
8271
+ out.push({
8272
+ ruleId: "server-only-import",
8273
+ file: file.path,
8274
+ line: lineAt(use.index),
8275
+ detail: mcp_verify_server_only_import({
8276
+ name,
8277
+ specifier
8278
+ })
8279
+ });
8280
+ }
8281
+ }
8282
+ return out;
8283
+ }
7894
8284
 
7895
8285
  //#endregion
7896
8286
  //#region ../mcp/src/verify-report.ts
@@ -7932,9 +8322,12 @@ function nextSteps(findings, target) {
7932
8322
  mcp_report_next_doc()
7933
8323
  ];
7934
8324
  }
7935
- /** 소스를 규칙과 대조해 고칠 수 있는 보고서로 만든다 */
7936
- function buildReport(files, target = "local") {
7937
- return reportFromFindings(verifySources(files), files.length, target);
8325
+ /**
8326
+ * 소스를 규칙과 대조해 고칠 수 있는 보고서로 만든다. `paths`(트리의 모든 파일 경로)를 주면 상대 경로 import의 대상이
8327
+ * 트리에 있는지도 본다(`local-import-exists`, #78)
8328
+ */
8329
+ function buildReport(files, target = "local", paths) {
8330
+ return reportFromFindings(verifySources(files, { paths }), files.length, target);
7938
8331
  }
7939
8332
  /**
7940
8333
  * 이미 찾은 위반으로 보고서를 만든다. 원격은 api가 작업 트리에서 `verifySources`를 돌려 위반만 돌려주고, 원인·수정
@@ -8015,7 +8408,7 @@ function verifyTree(files) {
8015
8408
  path,
8016
8409
  content
8017
8410
  }];
8018
- }), "remote");
8411
+ }), "remote", files.keys());
8019
8412
  }
8020
8413
 
8021
8414
  //#endregion
@@ -8048,7 +8441,21 @@ const WRITABLE_ROOT_FILES = new Set([
8048
8441
  ]);
8049
8442
  /** 읽기만 되는 루트 파일 — 의존성은 전용 경로로만 바꾼다(레지스트리 프록시와 잠금 파일을 함께 맞춘다) */
8050
8443
  const READONLY_ROOT_FILES = new Set(["package.json", "pnpm-lock.yaml"]);
8051
- const WRITABLE_DIRS = new Set(["src", "public"]);
8444
+ /**
8445
+ * 쓸 수 있는 폴더 — 코드(`src`), 정적 파일(`public`), 화면 문구 카탈로그(`messages`, 템플릿 `src/i18n`이 `messages/ko.json`을 import한다, #73).
8446
+ * 템플릿이 import하는 파일이 이 밖에 있으면 첫 작업 트리(source.tgz)를 풀 때 빠져 빌드·검사가 깨진다(#78)
8447
+ */
8448
+ const WRITABLE_DIRS = new Set([
8449
+ "src",
8450
+ "public",
8451
+ "messages"
8452
+ ]);
8453
+ /** 작업 트리 루트에 둘 수 있는 이름(폴더·파일) — 컨테이너 스냅샷이 이 이름만 묶는다(`container-tree.ts`) */
8454
+ const TRACKED_ROOT_NAMES = [
8455
+ ...WRITABLE_DIRS,
8456
+ ...WRITABLE_ROOT_FILES,
8457
+ ...READONLY_ROOT_FILES
8458
+ ];
8052
8459
  /** 형식·금지 세그먼트만 본다(위치 규칙은 보지 않는다). 통과하면 null, 아니면 이유 */
8053
8460
  function formatProblem(path) {
8054
8461
  if (typeof path !== "string" || path.length === 0) return "empty";
@@ -8184,7 +8591,7 @@ async function gunzip(bytes, maxBytes) {
8184
8591
  //#endregion
8185
8592
  //#region src/tree.ts
8186
8593
  /**
8187
- * 로컬 작업 트리 — 서버 작업 트리와 **같은 경로 규칙**(편집 Sandbox `paths.ts`의 `accessOf`)으로 고른다. `src/**`·`public/**`·루트 설정
8594
+ * 로컬 작업 트리 — 서버 작업 트리와 **같은 경로 규칙**(편집 Sandbox `paths.ts`의 `accessOf`)으로 고른다. `src/**`·`public/**`·`messages/**`·루트 설정
8188
8595
  * 파일·`package.json`·`pnpm-lock.yaml`만 올리고, `.env*`·`.mcp.json`·`.sayren/`·`node_modules`·`dist`·`package-lock.json` 등은 올리지 않는다.
8189
8596
  * 비밀·로컬 상태 파일(`isNeverUpload`)은 서버 경로 규칙과 별개로 CLI가 먼저 막는다 — 서버 규칙이 넓어져도 토큰을 올리지 않게.
8190
8597
  */
@@ -8403,7 +8810,7 @@ function lockDrift(packageJson, lock) {
8403
8810
  /** 잠금 파일 `importers: .:`의 이름 → specifier. 읽지 못하면 null */
8404
8811
  function lockImporterSpecifiers(lock) {
8405
8812
  const lines = lock.split(/\r?\n/);
8406
- const start = lines.findIndex((line) => line === "importers:");
8813
+ const start = lines.indexOf("importers:");
8407
8814
  if (start < 0) return null;
8408
8815
  const out = /* @__PURE__ */ new Map();
8409
8816
  let inRoot = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sayren",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "sayren 스토어프론트 CLI — 호스팅 중인 사이트의 소스를 내려받고(pull) 로컬에서 고친 뒤 배포(deploy)합니다",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "node": ">=20.10"
14
14
  },
15
15
  "dependencies": {
16
- "@sayren/store-sdk": "^0.28.1"
16
+ "@sayren/store-sdk": "^0.28.2"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",