@ahnseungchan/device-farm 0.0.0-stage → 0.1.1

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 (47) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -2
  4. package/dist/bin.js +79 -0
  5. package/dist/chaos-CIB7G5X5.js +453 -0
  6. package/dist/chunk-3GCH6TN3.js +86 -0
  7. package/dist/chunk-4UOXXMXL.js +74 -0
  8. package/dist/chunk-7OJDY276.js +16 -0
  9. package/dist/chunk-AVECSGEH.js +1462 -0
  10. package/dist/chunk-AXQNUPGC.js +416 -0
  11. package/dist/chunk-F4TMB4WY.js +54 -0
  12. package/dist/chunk-F6PZOBU6.js +167 -0
  13. package/dist/chunk-JD2L6T7E.js +56 -0
  14. package/dist/chunk-JZDYF3HL.js +169 -0
  15. package/dist/chunk-NJNTEWXE.js +134 -0
  16. package/dist/chunk-NLX3V7ZJ.js +134 -0
  17. package/dist/chunk-OUNLQI2N.js +34 -0
  18. package/dist/chunk-P2M55S53.js +110 -0
  19. package/dist/chunk-PLMSQ5KF.js +173 -0
  20. package/dist/chunk-PYZWMVJ6.js +302 -0
  21. package/dist/chunk-PZZQWRSU.js +283 -0
  22. package/dist/chunk-Q6CXF3NJ.js +102 -0
  23. package/dist/chunk-S6GHOP4B.js +111 -0
  24. package/dist/chunk-SWW42KTA.js +223 -0
  25. package/dist/chunk-UAHWEUDI.js +68 -0
  26. package/dist/chunk-VGPCTMUA.js +258 -0
  27. package/dist/chunk-WTTCWFOX.js +161 -0
  28. package/dist/chunk-ZE2GGXFO.js +169 -0
  29. package/dist/device-explore-PHS6SVEJ.js +64 -0
  30. package/dist/device-run-XPNRYXEL.js +60 -0
  31. package/dist/explore-allowlist-F44YQDVK.js +44 -0
  32. package/dist/index.d.ts +192 -0
  33. package/dist/index.js +24 -0
  34. package/dist/init-QFFEOJWY.js +29 -0
  35. package/dist/policy-check-AUWGFNHZ.js +75 -0
  36. package/dist/policy-watch-QPHI2P7Z.js +66 -0
  37. package/dist/qa-run-5LLQMLAM.js +59 -0
  38. package/dist/record-EGHVQFR7.js +152 -0
  39. package/dist/serve-XT32MAEN.js +1687 -0
  40. package/dist/server-3NM7ODUG.js +251 -0
  41. package/dist/templates/env.example +48 -0
  42. package/dist/templates/farm.config.ts +104 -0
  43. package/dist/web/assets/index-BCuSKs27.js +17 -0
  44. package/dist/web/assets/index-C3is0x-E.css +1 -0
  45. package/dist/web/favicon.svg +1 -0
  46. package/dist/web/index.html +14 -0
  47. package/package.json +95 -4
@@ -0,0 +1,173 @@
1
+ import {
2
+ isPrdName,
3
+ validateGenerated
4
+ } from "./chunk-SWW42KTA.js";
5
+ import {
6
+ tabNamesOf
7
+ } from "./chunk-NJNTEWXE.js";
8
+
9
+ // packages/core/src/flows/map-flow.ts
10
+ function mapRulesOf(app) {
11
+ return { homePath: app.homePath, homeTabName: app.homeTabName, tabPaths: app.tabPaths, forbiddenClickNames: app.guard.forbiddenClickNames };
12
+ }
13
+ var mapFlowPrd = "\uD654\uBA74 \uC9C0\uB3C4";
14
+ var startCursorOf = (rules) => ({ path: rules.homePath, texts: [] });
15
+ function blocksAt(edges, path2, rules) {
16
+ const tabs = tabNamesOf(rules).map((name) => ({ kind: "tab", step: { type: "click", target: { role: "link", name } }, to: rules.tabPaths[name], texts: [] }));
17
+ const own = edges.flatMap((edge) => {
18
+ if (edge.from !== path2 || rules.forbiddenClickNames.includes(edge.name)) return [];
19
+ const step = { type: "click", target: { role: edge.role, name: edge.name } };
20
+ if (edge.kind === "navigate" && edge.to) return [{ kind: "navigate", step, to: edge.to, texts: edge.texts ?? [] }];
21
+ if (edge.kind === "popup") return [{ kind: "popup", step, to: path2, texts: edge.texts ?? [] }];
22
+ return [];
23
+ });
24
+ return [...tabs, ...own];
25
+ }
26
+ function advance(edges, cursor, step, rules) {
27
+ if (step.type === "home") return startCursorOf(rules);
28
+ if (step.type === "expectText") {
29
+ return cursor.texts.includes(step.text) ? cursor : `\u300C${step.text}\u300D\uB294 \uC55E \uBE14\uB85D\uC744 \uB204\uB978 \uB4A4 \uC9C0\uB3C4\uC5D0 \uB0A8\uC740 \uBB38\uAD6C\uAC00 \uC544\uB2D9\uB2C8\uB2E4.`;
30
+ }
31
+ const block = blocksAt(edges, cursor.path, rules).find(
32
+ (item) => item.step.target.role === step.target.role && item.step.target.name === step.target.name
33
+ );
34
+ if (!block) return `\u300C${step.target.name}\u300D\uB294 ${cursor.path} \uD654\uBA74 \uC9C0\uB3C4\uC5D0 \uC5C6\uB294 \uBC84\uD2BC\uC785\uB2C8\uB2E4.`;
35
+ return { path: block.to, texts: block.texts };
36
+ }
37
+ function replay(edges, steps, rules) {
38
+ let cursor = startCursorOf(rules);
39
+ for (const [index, step] of steps.entries()) {
40
+ const next = advance(edges, cursor, step, rules);
41
+ if (typeof next === "string") return `${index + 1}\uBC88\uC9F8 \uBE14\uB85D: ${next}`;
42
+ cursor = next;
43
+ }
44
+ return cursor;
45
+ }
46
+ function mapFlowError(edges, steps, rules) {
47
+ if (!steps.some((step) => step.type === "click")) return "\uB204\uB974\uB294 \uBE14\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.";
48
+ if (!steps.some((step) => step.type === "expectText")) return "\uBB38\uAD6C \uD655\uC778 \uBE14\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.";
49
+ const result = replay(edges, steps, rules);
50
+ return typeof result === "string" ? result : null;
51
+ }
52
+
53
+ // packages/engine/src/flows/store.ts
54
+ import { createHash } from "crypto";
55
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "fs";
56
+ import path from "path";
57
+ var flowSources = ["ai_generated", "map_built", "recorded"];
58
+ var flowDirs = {
59
+ ready: path.resolve("scenarios/flows"),
60
+ generated: path.resolve("test-results/flows/generated")
61
+ };
62
+ var prdSearchRoots = ["app", "docs/specs"];
63
+ var skippedDirs = /* @__PURE__ */ new Set(["node_modules", ".git", ".next"]);
64
+ function readJson(file, fallback) {
65
+ try {
66
+ return JSON.parse(readFileSync(file, "utf8"));
67
+ } catch {
68
+ return fallback;
69
+ }
70
+ }
71
+ function writeJson(file, value) {
72
+ mkdirSync(path.dirname(file), { recursive: true });
73
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}
74
+ `);
75
+ }
76
+ function prdRoot() {
77
+ const root = process.env.FLOW_PRD_ROOT?.trim();
78
+ return root && existsSync(root) ? realpathSync(root) : null;
79
+ }
80
+ function walkMarkdown(dir, inSpecs, out) {
81
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
82
+ if (skippedDirs.has(entry.name)) continue;
83
+ const full = path.join(dir, entry.name);
84
+ if (entry.isDirectory()) walkMarkdown(full, inSpecs || entry.name === "specs", out);
85
+ else if (inSpecs && entry.name.endsWith(".md")) out.push(full);
86
+ }
87
+ }
88
+ function listPrds(root = prdRoot()) {
89
+ if (!root) return [];
90
+ const files = [];
91
+ for (const sub of prdSearchRoots) {
92
+ const dir = path.join(root, sub);
93
+ if (existsSync(dir)) walkMarkdown(dir, sub === "docs/specs", files);
94
+ }
95
+ return files.map((file) => path.relative(root, file).split(path.sep).join("/")).filter(isPrdName).sort();
96
+ }
97
+ function readPrd(name, root = prdRoot()) {
98
+ if (!root || !isPrdName(name)) return null;
99
+ const file = path.resolve(root, name);
100
+ if (!file.startsWith(root + path.sep) || !existsSync(file)) return null;
101
+ return readFileSync(file, "utf8");
102
+ }
103
+ function prdHash(body) {
104
+ return createHash("sha256").update(body, "utf8").digest("hex");
105
+ }
106
+ var hashFile = (dirs) => path.join(dirs.generated, "hashes.json");
107
+ var candidateFile = (dirs) => path.join(dirs.generated, "scenarios.json");
108
+ function readHashes(dirs = flowDirs) {
109
+ return readJson(hashFile(dirs), {});
110
+ }
111
+ function writeHash(name, hash, dirs = flowDirs) {
112
+ writeJson(hashFile(dirs), { ...readHashes(dirs), [name]: hash });
113
+ }
114
+ function readCandidates(dirs = flowDirs) {
115
+ const parsed = readJson(candidateFile(dirs), { items: [] });
116
+ return Array.isArray(parsed.items) ? parsed.items : [];
117
+ }
118
+ function writeCandidates(prd, items, dirs = flowDirs) {
119
+ writeJson(candidateFile(dirs), { items: [...readCandidates(dirs).filter((item) => item.prd !== prd), ...items] });
120
+ }
121
+ function addCandidate(item, dirs = flowDirs) {
122
+ writeJson(candidateFile(dirs), { items: [...readCandidates(dirs).filter((entry) => entry.id !== item.id), item] });
123
+ }
124
+ function readReady(dirs = flowDirs) {
125
+ if (!existsSync(dirs.ready)) return [];
126
+ return readdirSync(dirs.ready).filter((name) => name.endsWith(".json")).sort().flatMap((name) => {
127
+ const parsed = readJson(path.join(dirs.ready, name), null);
128
+ return parsed && parsed.status === "ready" && flowSources.includes(parsed.source) ? [parsed] : [];
129
+ });
130
+ }
131
+ function acceptGenerated(items, prd, prdBody, forbiddenClickNames, dirs = flowDirs) {
132
+ const readyIds = new Set(readReady(dirs).map((item) => item.id));
133
+ return items.map((item, index) => validateGenerated(item, index, readyIds, prd, prdBody, forbiddenClickNames));
134
+ }
135
+ function readyDocument(item) {
136
+ return { id: item.id, name: item.name, source: item.source, status: "ready", prd: item.prd, requirement: item.requirement, steps: item.steps };
137
+ }
138
+ function confirmFlow(id, dirs = flowDirs) {
139
+ const items = readCandidates(dirs);
140
+ const item = items.find((entry) => entry.id === id && entry.status === "generated");
141
+ if (!item || !item.proposal || item.proposal === "keep") return false;
142
+ const file = path.join(dirs.ready, `${item.id}.json`);
143
+ if (item.proposal === "delete") {
144
+ if (existsSync(file)) unlinkSync(file);
145
+ } else {
146
+ writeJson(file, readyDocument(item));
147
+ }
148
+ writeJson(candidateFile(dirs), { items: items.filter((entry) => entry.id !== id) });
149
+ return true;
150
+ }
151
+ function confirmFlows(ids, dirs = flowDirs) {
152
+ const targets = ids ?? readCandidates(dirs).filter((item) => item.status === "generated").map((item) => item.id);
153
+ return targets.filter((id) => confirmFlow(id, dirs)).length;
154
+ }
155
+
156
+ export {
157
+ mapRulesOf,
158
+ mapFlowPrd,
159
+ mapFlowError,
160
+ flowDirs,
161
+ prdRoot,
162
+ listPrds,
163
+ readPrd,
164
+ prdHash,
165
+ readHashes,
166
+ writeHash,
167
+ readCandidates,
168
+ writeCandidates,
169
+ addCandidate,
170
+ readReady,
171
+ acceptGenerated,
172
+ confirmFlows
173
+ };
@@ -0,0 +1,302 @@
1
+ import {
2
+ askClaude,
3
+ buildFrontFigma,
4
+ policyRoot,
5
+ readAllPageRules,
6
+ readChanges,
7
+ readFigmaCache,
8
+ readFrontFigmaMap,
9
+ readPageRules,
10
+ readSnapshot,
11
+ readSources,
12
+ recordSnapshot,
13
+ removePageRules,
14
+ writeFigmaCache,
15
+ writePageRules
16
+ } from "./chunk-VGPCTMUA.js";
17
+ import {
18
+ buildPageRules,
19
+ extractPrompt,
20
+ parseExtraction,
21
+ shortHash,
22
+ storageToText
23
+ } from "./chunk-F6PZOBU6.js";
24
+
25
+ // packages/engine/src/policy/confluence.ts
26
+ import { createHash } from "crypto";
27
+ var defaultPolicyTitleQuery = "\uC815\uCC45";
28
+ var searchPageSize = 50;
29
+ var pageConcurrency = 5;
30
+ function confluenceConfigFromEnv(source, env = process.env) {
31
+ const email = env.CONFLUENCE_EMAIL?.trim();
32
+ const token = env.CONFLUENCE_API_TOKEN?.trim();
33
+ const cloudId = env.CONFLUENCE_CLOUD_ID?.trim() || source?.cloudId;
34
+ const spaceKey = env.CONFLUENCE_SPACE_KEY?.trim() || source?.spaceKey;
35
+ if (!email || !token || !cloudId || !spaceKey) return null;
36
+ return { email, token, cloudId, spaceKey, titleQuery: source?.titleQuery || defaultPolicyTitleQuery, siteUrl: (source?.siteUrl ?? "").replace(/\/+$/, "") };
37
+ }
38
+ var wikiBase = (config) => `https://api.atlassian.com/ex/confluence/${config.cloudId}/wiki`;
39
+ var ConfluenceError = class extends Error {
40
+ constructor(status, message) {
41
+ super(message);
42
+ this.status = status;
43
+ }
44
+ status;
45
+ };
46
+ async function getJson(config, pathAndQuery, fetchImpl) {
47
+ const auth = Buffer.from(`${config.email}:${config.token}`).toString("base64");
48
+ const response = await fetchImpl(`${wikiBase(config)}${pathAndQuery}`, { headers: { Authorization: `Basic ${auth}`, Accept: "application/json" } });
49
+ if (!response.ok) throw new ConfluenceError(response.status, `Confluence ${response.status}: ${pathAndQuery.split("?")[0]}`);
50
+ return await response.json();
51
+ }
52
+ async function searchPolicyPages(config, fetchImpl) {
53
+ const cql = `space=${config.spaceKey} AND type=page AND title~"${config.titleQuery}"`;
54
+ let next = `/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${searchPageSize}`;
55
+ const pages = [];
56
+ while (next) {
57
+ const response = await getJson(config, next, fetchImpl);
58
+ for (const item of response.results) if (item.content?.type === "page") pages.push({ id: item.content.id, title: item.content.title });
59
+ next = response._links?.next;
60
+ }
61
+ return pages;
62
+ }
63
+ function hashBody(body) {
64
+ return createHash("sha256").update(body, "utf8").digest("hex");
65
+ }
66
+ async function readPolicyPageWithBody(config, id, fetchImpl) {
67
+ const page = await getJson(config, `/api/v2/pages/${encodeURIComponent(id)}?body-format=storage`, fetchImpl);
68
+ const storage = page.body?.storage?.value ?? "";
69
+ return {
70
+ page: {
71
+ id: page.id,
72
+ title: page.title,
73
+ version: page.version.number,
74
+ modifiedAt: page.version.createdAt,
75
+ hash: hashBody(storage),
76
+ webUrl: `${config.siteUrl || wikiBase(config)}${page._links?.webui ?? `/pages/${page.id}`}`
77
+ },
78
+ storage
79
+ };
80
+ }
81
+ async function readPolicyPage(config, id, fetchImpl) {
82
+ return (await readPolicyPageWithBody(config, id, fetchImpl)).page;
83
+ }
84
+ async function fetchPolicySnapshot(config, registeredIds, fetchImpl, now = () => /* @__PURE__ */ new Date()) {
85
+ const found = await searchPolicyPages(config, fetchImpl);
86
+ const ids = [.../* @__PURE__ */ new Set([...found.map((page) => page.id), ...registeredIds])];
87
+ const pages = {};
88
+ const failed = [];
89
+ for (let start = 0; start < ids.length; start += pageConcurrency) {
90
+ await Promise.all(
91
+ ids.slice(start, start + pageConcurrency).map(async (id) => {
92
+ try {
93
+ pages[id] = await readPolicyPage(config, id, fetchImpl);
94
+ } catch (error) {
95
+ failed.push({ id, message: error instanceof Error ? error.message : String(error) });
96
+ }
97
+ })
98
+ );
99
+ }
100
+ return { snapshot: { checkedAt: now().toISOString(), pages }, failed };
101
+ }
102
+
103
+ // packages/engine/src/policy/figma.ts
104
+ function figmaRefs(storage) {
105
+ const refs = /* @__PURE__ */ new Set();
106
+ const pattern = /figma\.com\/(?:design|file|proto)\/([A-Za-z0-9]+)[^"'\s<>]*?node-id=([0-9]+(?:-|%3A|:)[0-9]+)/g;
107
+ for (const match of storage.replaceAll("&amp;", "&").matchAll(pattern)) {
108
+ refs.add(`${match[1]}:${decodeURIComponent(match[2]).replace("-", ":")}`);
109
+ }
110
+ return [...refs].sort();
111
+ }
112
+ function collectTexts(node, into) {
113
+ if (!node) return;
114
+ if (node.type === "TEXT" && node.characters?.trim()) into.push(node.characters.trim());
115
+ for (const child of node.children ?? []) collectTexts(child, into);
116
+ }
117
+ async function fetchFigmaTexts(token, refs, fetchImpl) {
118
+ const byFile = /* @__PURE__ */ new Map();
119
+ for (const ref of refs) {
120
+ const [fileKey, ...rest] = ref.split(":");
121
+ byFile.set(fileKey, [...byFile.get(fileKey) ?? [], rest.join(":")]);
122
+ }
123
+ const result = {};
124
+ for (const [fileKey, ids] of byFile) {
125
+ const response = await fetchImpl(`https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(ids.join(","))}`, { headers: { "X-Figma-Token": token } });
126
+ if (!response.ok) throw new Error(`Figma ${response.status}: ${fileKey}`);
127
+ const body = await response.json();
128
+ for (const id of ids) {
129
+ const texts = [];
130
+ collectTexts(body.nodes?.[id]?.document, texts);
131
+ result[`${fileKey}:${id}`] = { hash: shortHash(texts.join("\n")), texts };
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ // packages/engine/src/policy/extract.ts
138
+ var pagesToExtract = (snapshot, root = policyRoot) => Object.values(snapshot.pages).filter((page) => readPageRules(page.id, root)?.pageHash !== page.hash);
139
+ async function extractPage(pageId, deps) {
140
+ const root = deps.root ?? policyRoot;
141
+ const { page, storage } = await readPolicyPageWithBody(deps.config, pageId, deps.fetchImpl);
142
+ const bodyText = storageToText(storage);
143
+ const { screens, candidates } = parseExtraction(await deps.ask(extractPrompt(page.title, bodyText, deps.knownScreens, deps.appName)));
144
+ const { rules, dropped } = buildPageRules(page, bodyText, candidates, readPageRules(page.id, root)?.rules ?? []);
145
+ writePageRules(
146
+ { pageId: page.id, pageTitle: page.title, pageHash: page.hash, extractedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(), screens, figma: figmaRefs(storage), rules, dropped },
147
+ root
148
+ );
149
+ deps.log(`\uC815\uCC45 \uADDC\uCE59 \u300C${page.title}\u300D ${rules.length}\uAC1C \xB7 \uBC84\uB9BC ${dropped.length}\uAC1C`);
150
+ }
151
+ async function refreshRules(snapshot, deps) {
152
+ const root = deps.root ?? policyRoot;
153
+ const targets = pagesToExtract(snapshot, root);
154
+ const failed = [];
155
+ let extracted = 0;
156
+ for (const [index, page] of targets.entries()) {
157
+ deps.onProgress?.(index, targets.length);
158
+ try {
159
+ await extractPage(page.id, deps);
160
+ extracted += 1;
161
+ } catch (error) {
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ failed.push({ id: page.id, message });
164
+ deps.log(`\uC815\uCC45 \uADDC\uCE59 \uBF51\uAE30 \uC2E4\uD328 \u300C${page.title}\u300D: ${message}`);
165
+ }
166
+ }
167
+ const stale = readAllPageRules(root).filter((rules) => !snapshot.pages[rules.pageId]);
168
+ for (const rules of stale) removePageRules(rules.pageId, root);
169
+ return { extracted, removed: stale.length, failed };
170
+ }
171
+
172
+ // packages/core/src/policy/schedule.ts
173
+ var policyCheckHour = 2;
174
+ function lastScheduledAt(now, hour = policyCheckHour) {
175
+ const scheduled = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, 0, 0, 0);
176
+ if (scheduled.getTime() > now.getTime()) scheduled.setDate(scheduled.getDate() - 1);
177
+ return scheduled;
178
+ }
179
+ function nextScheduledAt(now, hour = policyCheckHour) {
180
+ const next = lastScheduledAt(now, hour);
181
+ next.setDate(next.getDate() + 1);
182
+ return next;
183
+ }
184
+ function isPolicyCheckDue(lastCheckedAt, now, hour = policyCheckHour) {
185
+ const last = lastCheckedAt ? Date.parse(lastCheckedAt) : Number.NaN;
186
+ return !Number.isFinite(last) || last < lastScheduledAt(now, hour).getTime();
187
+ }
188
+
189
+ // packages/server/src/policy-watch.ts
190
+ var policyPollMs = 60 * 6e4;
191
+ var recentChanges = 50;
192
+ function rulesSummary() {
193
+ return readAllPageRules().map((page) => ({
194
+ pageId: page.pageId,
195
+ pageTitle: page.pageTitle,
196
+ extractedAt: page.extractedAt,
197
+ screens: page.screens,
198
+ figma: page.figma.length,
199
+ rules: page.rules,
200
+ dropped: page.dropped
201
+ }));
202
+ }
203
+ function refreshFrontFigma(figma, log) {
204
+ const frontRoot = figma.frontRepo?.trim() || process.env.FLOW_PRD_ROOT?.trim();
205
+ if (!frontRoot) return;
206
+ try {
207
+ const map = buildFrontFigma(frontRoot, process.env.FARM_FRONT_REF?.trim() || figma.ref, void 0, void 0, figma.appDir);
208
+ log(`\uD504\uB860\uD2B8 ${map.ref}@${map.commit} \uD654\uBA74 ${Object.keys(map.routes).length}\uAC1C\uC5D0 \uD53C\uADF8\uB9C8 \uB178\uB4DC \uC5F0\uACB0`);
209
+ } catch (error) {
210
+ log(`\uD504\uB860\uD2B8 \uD53C\uADF8\uB9C8 \uC5F0\uACB0 \uC2E4\uD328: ${error instanceof Error ? error.message : String(error)}`);
211
+ }
212
+ }
213
+ async function refreshFigma(fetchImpl, log) {
214
+ const token = process.env.FIGMA_TOKEN?.trim();
215
+ const front = Object.values(readFrontFigmaMap()?.routes ?? {}).flat();
216
+ const refs = [.../* @__PURE__ */ new Set([...readAllPageRules().flatMap((page) => page.figma), ...front])];
217
+ if (!token || refs.length === 0) return;
218
+ const texts = await fetchFigmaTexts(token, refs, fetchImpl);
219
+ writeFigmaCache({ ...readFigmaCache(), ...texts });
220
+ log(`\uD53C\uADF8\uB9C8 \uB178\uB4DC ${Object.keys(texts).length}\uAC1C \uBB38\uAD6C \uC77D\uC74C`);
221
+ }
222
+ function startPolicyWatch(deps) {
223
+ const now = deps.now ?? (() => /* @__PURE__ */ new Date());
224
+ const fetchImpl = deps.fetchImpl ?? ((url, init) => fetch(url, init));
225
+ const ask = deps.ask ?? askClaude;
226
+ let running = false;
227
+ let lastError = null;
228
+ let failed = [];
229
+ let extractFailed = [];
230
+ let progress = null;
231
+ const run = async () => {
232
+ const config = confluenceConfigFromEnv(deps.app.policy.confluence);
233
+ if (!config || running) return;
234
+ running = true;
235
+ try {
236
+ const result = await fetchPolicySnapshot(config, readSources().pages.map((page) => page.pageId), fetchImpl, now);
237
+ const changes = recordSnapshot(result.snapshot);
238
+ failed = result.failed;
239
+ lastError = null;
240
+ deps.log(`\uC815\uCC45 ${Object.keys(result.snapshot.pages).length}\uAC1C \uD655\uC778 \xB7 \uBC14\uB01C ${changes.length}\uAC1C${failed.length ? ` \xB7 \uC77D\uAE30 \uC2E4\uD328 ${failed.length}\uAC1C` : ""}`);
241
+ const refreshed = await refreshRules(result.snapshot, {
242
+ config,
243
+ ask,
244
+ fetchImpl,
245
+ knownScreens: deps.knownScreens(),
246
+ appName: deps.app.name,
247
+ log: deps.log,
248
+ now,
249
+ onProgress: (done, total) => progress = { done, total }
250
+ });
251
+ extractFailed = refreshed.failed;
252
+ refreshFrontFigma(deps.app.figma, deps.log);
253
+ await refreshFigma(fetchImpl, deps.log);
254
+ } catch (error) {
255
+ lastError = error instanceof Error ? error.message : String(error);
256
+ deps.log(`\uC815\uCC45 \uD655\uC778 \uC2E4\uD328: ${lastError}`);
257
+ } finally {
258
+ running = false;
259
+ progress = null;
260
+ }
261
+ };
262
+ const tick = () => {
263
+ if (isPolicyCheckDue(readSnapshot().checkedAt || null, now())) void run();
264
+ };
265
+ tick();
266
+ const timer = setInterval(tick, policyPollMs);
267
+ timer.unref();
268
+ return {
269
+ state: () => {
270
+ const snapshot = readSnapshot();
271
+ return {
272
+ configured: confluenceConfigFromEnv(deps.app.policy.confluence) !== null,
273
+ figmaConfigured: Boolean(process.env.FIGMA_TOKEN?.trim()),
274
+ running,
275
+ progress,
276
+ checkedAt: snapshot.checkedAt || null,
277
+ nextAt: nextScheduledAt(now()).toISOString(),
278
+ lastError,
279
+ failed,
280
+ extractFailed,
281
+ pages: Object.values(snapshot.pages).sort((a, b) => a.modifiedAt < b.modifiedAt ? 1 : -1),
282
+ changes: readChanges(recentChanges),
283
+ rules: rulesSummary()
284
+ };
285
+ },
286
+ checkNow: () => {
287
+ if (running) return false;
288
+ void run();
289
+ return true;
290
+ },
291
+ stop: () => clearInterval(timer)
292
+ };
293
+ }
294
+
295
+ export {
296
+ confluenceConfigFromEnv,
297
+ fetchPolicySnapshot,
298
+ extractPage,
299
+ refreshFrontFigma,
300
+ refreshFigma,
301
+ startPolicyWatch
302
+ };