@ngockhoale/ukit 3.0.10 → 3.0.11

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 (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/ukit +5 -9
  3. package/package.json +1 -1
  4. package/scripts/bench/memory-baseline.mjs +1 -1
  5. package/scripts/bench/memory-bench.mjs +1 -1
  6. package/scripts/bench/memory-canary.mjs +12 -2
  7. package/scripts/bench/recorder-overhead.mjs +1 -1
  8. package/scripts/bench/sqlite-spike.mjs +1 -1
  9. package/scripts/measure-decision-gateway.mjs +1 -1
  10. package/src/cli/commands/memory.js +3 -0
  11. package/src/cli/deadline.js +19 -0
  12. package/src/core/agentRuntime/eventStore.js +38 -10
  13. package/src/core/agentRuntime/supervisor.js +41 -9
  14. package/src/core/agentRuntime/vmEngine.js +33 -2
  15. package/src/core/codeintel/freshness.js +13 -9
  16. package/src/core/codeintel/retriever.js +25 -20
  17. package/src/core/fileOps.js +19 -4
  18. package/src/core/memory/memoryFlags.js +14 -6
  19. package/src/core/metadata.js +111 -81
  20. package/src/core/token/index.js +69 -28
  21. package/src/index/buildIndex.js +7 -0
  22. package/src/index/impactContext.js +14 -5
  23. package/src/render/instructionRenderer.js +71 -19
  24. package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
  25. package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
  26. package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
  27. package/template_project/.claude/skills/docx/scripts/document.py +31 -8
  28. package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
  29. package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
  30. package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
  31. package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
  32. package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
  33. package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
  34. package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
  35. package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
  36. package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
  37. package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
  38. package/template_project/.claude/ukit/index/query-index.mjs +13 -11
  39. package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
  40. package/template_project/.claude/ukit/index/route-task.mjs +44 -25
  41. package/template_project/.claude/ukit/index/triage.mjs +12 -11
  42. package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
  43. package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
  44. package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
  46. package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
@@ -32,10 +32,24 @@ fi
32
32
 
33
33
  NOW_UTC=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
34
34
 
35
- node -e '
36
- // Deadline must exceed the bounded lock wait in this block (maxWaitMs = 5000) so the
37
- // watchdog can never fire while the mutation is still legally waiting for the lock.
35
+ # Single locked read-modify-write for settings.local.json + permission-usage.json —
36
+ # the same serialization point auto-allow-bash.sh uses (<usage>.lock). See the
37
+ # auto-allow header for the full rationale: a stale-snapshot rewrite can silently
38
+ # drop allow rules, and Claude Code re-reads settings.local.json so a torn write
39
+ # would drop the ENTIRE allow list.
40
+ #
41
+ # TASK-008 fail-closed policy (same as auto-allow-bash, TASK-028): the acquisition
42
+ # budget derives from the remaining hook deadline minus a cleanup reserve, capped
43
+ # at a short slice. On busy/abort — or a missing runtime module on a degraded
44
+ # install — the mutation is SKIPPED (fail closed): sensitive permission state is
45
+ # never written unlocked. The old inline lock ran fn() UNLOCKED on non-EEXIST
46
+ # errors and after maxWaitMs (C79-10).
47
+ UKIT_RUNTIME_DIR="$SCRIPT_DIR/../ukit/runtime" node -e '
48
+ // Deadline must exceed the derived lock budget in this block (remaining deadline
49
+ // minus reserve, capped at a short slice) so the watchdog can never fire while
50
+ // the mutation is still legally waiting for the lock.
38
51
  const HOOK_DEADLINE_MS = Number.parseInt(process.env.UKIT_HOOK_DEADLINE_MS || "", 10) || 8000;
52
+ const LOCK_STARTED_AT = Date.now();
39
53
 
40
54
  // SPEC §8: a failed prune abandons configured work — announce it (success stays silent).
41
55
  // Unpruned rules are harmless (stale entries simply never match again), so degrade is
@@ -52,9 +66,9 @@ setTimeout(() => {
52
66
  emitDegrade("prune exceeded its deadline; stale Bash auto-allow rules were not pruned this session — the next session retries.");
53
67
  process.exit(0);
54
68
  }, HOOK_DEADLINE_MS).unref();
55
- const crypto = require("crypto");
56
69
  const fsp = require("fs").promises;
57
70
  const path = require("path");
71
+ const { pathToFileURL } = require("url");
58
72
 
59
73
  const settingsPath = process.argv[1];
60
74
  const usagePath = process.argv[2];
@@ -63,6 +77,15 @@ const ttlDays = Math.max(1, Number(process.argv[4]) || 30);
63
77
  const nowUtc = process.argv[5];
64
78
  const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
65
79
 
80
+ async function pathExists(filePath) {
81
+ try {
82
+ await fsp.access(filePath);
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
66
89
  async function readJson(filePath) {
67
90
  try {
68
91
  return JSON.parse(await fsp.readFile(filePath, "utf8"));
@@ -118,84 +141,7 @@ async function pruneAuditLog() {
118
141
  await fsp.rename(tempPath, auditPath);
119
142
  }
120
143
 
121
- // Same lock point as auto-allow-bash.sh (<usage>.lock): the two hooks serialize their
122
- // settings/usage rewrites against each other and against parallel invocations. Atomic
123
- // writes because Claude Code re-reads settings.local.json — a torn write would drop the
124
- // ENTIRE allow list and every command would prompt again. Fail-open after 5s: a hook
125
- // must never hang the chain.
126
- function isPidAlive(pid) {
127
- if (!Number.isInteger(pid) || pid <= 0) return false;
128
- try {
129
- process.kill(pid, 0);
130
- return true;
131
- } catch (error) {
132
- return error?.code === "EPERM";
133
- }
134
- }
135
-
136
- async function readLockOwner(lockPath) {
137
- const owner = await readJson(path.join(lockPath, "owner"));
138
- return owner && Number.isInteger(owner.pid) && typeof owner.token === "string"
139
- ? owner
140
- : null;
141
- }
142
-
143
- async function withLock(lockPath, fn) {
144
- const staleMs = 10000;
145
- const maxWaitMs = 5000;
146
- const startedAt = Date.now();
147
- const ownerToken = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
148
- let held = false;
149
- while (!held) {
150
- try {
151
- await fsp.mkdir(path.dirname(lockPath), { recursive: true });
152
- await fsp.mkdir(lockPath);
153
- try {
154
- await fsp.writeFile(path.join(lockPath, "owner"), JSON.stringify({
155
- pid: process.pid,
156
- token: ownerToken,
157
- ts: Date.now(),
158
- }));
159
- } catch (error) {
160
- try {
161
- await fsp.rm(lockPath, { recursive: true, force: true });
162
- } catch {}
163
- throw error;
164
- }
165
- held = true;
166
- } catch (error) {
167
- if (!error || error.code !== "EEXIST") return fn();
168
- try {
169
- const stat = await fsp.stat(lockPath);
170
- if (Date.now() - stat.mtimeMs > staleMs) {
171
- const owner = await readLockOwner(lockPath);
172
- if (!owner || !isPidAlive(owner.pid)) {
173
- try {
174
- await fsp.rm(lockPath, { recursive: true, force: true });
175
- continue;
176
- } catch {}
177
- }
178
- }
179
- } catch {}
180
- if (Date.now() - startedAt > maxWaitMs) break;
181
- await new Promise((resolve) => setTimeout(resolve, 3 + Math.floor(Math.random() * 9)));
182
- }
183
- }
184
- try {
185
- return await fn();
186
- } finally {
187
- if (held) {
188
- const owner = await readLockOwner(lockPath);
189
- if (owner?.token === ownerToken) {
190
- try {
191
- await fsp.rm(lockPath, { recursive: true, force: true });
192
- } catch {}
193
- }
194
- }
195
- }
196
- }
197
-
198
- withLock(`${usagePath}.lock`, async () => {
144
+ async function pruneMutation() {
199
145
  // Rotate the audit log inside the same lock as the rule prune: both are
200
146
  // read-modify-write passes on files these two hooks share.
201
147
  await pruneAuditLog();
@@ -235,7 +181,38 @@ withLock(`${usagePath}.lock`, async () => {
235
181
  if (prunedCount > 0) {
236
182
  await appendAudit("auto_prune_summary", { prunedCount, ttlDays });
237
183
  }
238
- }).catch((error) => {
184
+ }
185
+
186
+ (async () => {
187
+ // Fail-closed shared lock (TASK-008, same contract as auto-allow-bash.sh):
188
+ // the lock lives beside the usage file, and a degraded install without the
189
+ // runtime module must SKIP the prune rather than write permission state
190
+ // unlocked — unpruned rules are harmless, torn/lost writes are not.
191
+ const runtimeDir = process.env.UKIT_RUNTIME_DIR || "";
192
+ const lockModulePath = runtimeDir ? path.join(runtimeDir, "async-lock.mjs") : "";
193
+ let lockModule = null;
194
+ if (lockModulePath && (await pathExists(lockModulePath))) {
195
+ try {
196
+ lockModule = await import(pathToFileURL(lockModulePath).href);
197
+ } catch {
198
+ lockModule = null;
199
+ }
200
+ }
201
+ if (!lockModule || typeof lockModule.withAsyncLock !== "function") {
202
+ // Degraded install (runtime module missing): fail closed — skip the write,
203
+ // exit 0, retry next session.
204
+ return;
205
+ }
206
+ const deadlineMs = lockModule.lockBudgetMs({
207
+ hookDeadlineMs: HOOK_DEADLINE_MS,
208
+ startedAt: LOCK_STARTED_AT,
209
+ });
210
+ const outcome = await lockModule.withAsyncLock(usagePath, { deadlineMs }, pruneMutation);
211
+ if (!outcome.ok) {
212
+ // busy or aborted: fail closed — write nothing unlocked, exit 0.
213
+ return;
214
+ }
215
+ })().catch((error) => {
239
216
  emitDegrade(`prune failed (${error?.message ?? error}); stale Bash auto-allow rules were not pruned this session — the next session retries.`);
240
217
  process.exit(0);
241
218
  });
@@ -133,23 +133,45 @@ const basename = (token) => token.split('/').pop();
133
133
  // caller fails closed, never silently allows). UKIT_REALPATH_MISSING=1 (set by
134
134
  // the wrapper when no system realpath exists) forces the same fail-closed —
135
135
  // the pre-port gate could not prove containment without realpath.
136
- function canonicalPath(p, env) {
136
+ //
137
+ // TASK-007 (C79-09): every fs probe below is ASYNC (libuv) and raced against
138
+ // the remaining scan budget — never realpathSync/lstatSync. A sync probe on a
139
+ // stalled mount pins the event loop, so runHook's Promise.race deadline timer
140
+ // can never fire and the gate freezes instead of refusing. A timed-out or
141
+ // erroring probe degrades to the lexical ancestor walk; a timed-out final
142
+ // realpath returns '' => downstream fail-closed.
143
+ const FS_OP_BUDGET_MS = 3000;
144
+
145
+ function timedFs(op, deadlineMs, signal) {
146
+ if (signal && signal.aborted) return Promise.reject(new Error('aborted'));
147
+ const budget = Number.isFinite(deadlineMs) ? deadlineMs : FS_OP_BUDGET_MS;
148
+ let timer;
149
+ const timeout = new Promise((_, reject) => {
150
+ timer = setTimeout(() => reject(new Error('fs probe timed out')), Math.max(0, budget));
151
+ if (timer && typeof timer.unref === 'function') timer.unref();
152
+ });
153
+ return Promise.race([op(), timeout]).finally(() => clearTimeout(timer));
154
+ }
155
+
156
+ async function canonicalPath(p, env, deadlineMs, signal) {
137
157
  if (env && env.UKIT_REALPATH_MISSING === '1') return '';
158
+ const deadlineAt = Date.now() + (Number.isFinite(deadlineMs) ? deadlineMs : FS_OP_BUDGET_MS);
159
+ const remaining = () => deadlineAt - Date.now();
138
160
  try {
139
- const out = fs.realpathSync.native(p);
161
+ const out = await timedFs(() => fsp.realpath(p), remaining(), signal);
140
162
  if (out) return out;
141
163
  } catch {}
142
164
  let rest = '';
143
165
  let cur = p;
144
- const existsOrLink = (q) => {
166
+ const existsOrLink = async (q) => {
145
167
  try {
146
- fs.lstatSync(q);
168
+ await timedFs(() => fsp.lstat(q), remaining(), signal);
147
169
  return true;
148
170
  } catch {
149
171
  return false;
150
172
  }
151
173
  };
152
- while (cur && cur !== '/' && !existsOrLink(cur)) {
174
+ while (cur && cur !== '/' && !(await existsOrLink(cur))) {
153
175
  rest = `/${path.basename(cur)}${rest}`;
154
176
  const parent = cur.slice(0, cur.lastIndexOf('/')) || (cur.startsWith('/') ? '/' : cur);
155
177
  if (parent === cur) break;
@@ -157,15 +179,18 @@ function canonicalPath(p, env) {
157
179
  }
158
180
  if (!cur) cur = '/';
159
181
  try {
160
- const out = fs.realpathSync.native(cur);
182
+ const out = await timedFs(() => fsp.realpath(cur), remaining(), signal);
161
183
  return `${out}${rest}`;
162
184
  } catch {
163
185
  return '';
164
186
  }
165
187
  }
166
188
 
167
- function evaluate({ command, projectRoot, env }) {
189
+ async function evaluate({ command, projectRoot, env, deadlineMs, signal }) {
168
190
  if (!command) return { code: 0, stdout: '', stderr: '' };
191
+ if (signal && signal.aborted) return TIMEOUT_REFUSAL;
192
+ const deadlineAt = Date.now() + (Number.isFinite(deadlineMs) ? deadlineMs : FS_OP_BUDGET_MS);
193
+ const remaining = () => deadlineAt - Date.now();
169
194
 
170
195
  // Quoted-span scrub: only when the invoked command is a pure text tool AND
171
196
  // its output cannot reach a shell — `echo "rm -rf /" | bash` executes the
@@ -185,7 +210,7 @@ function evaluate({ command, projectRoot, env }) {
185
210
  }
186
211
 
187
212
  // rm handling: allow safe cleanup targets only; everything else defers.
188
- const projectRootCanon = canonicalPath(projectRoot, env);
213
+ const projectRootCanon = await canonicalPath(projectRoot, env, remaining(), signal);
189
214
 
190
215
  let verdictUnsafe = false;
191
216
  let verdictGeneric = false;
@@ -251,7 +276,7 @@ function evaluate({ command, projectRoot, env }) {
251
276
  allSafe = false;
252
277
  } else {
253
278
  const abs = target.startsWith('/') ? target : `${projectRootCanon}/${target}`;
254
- const canon = canonicalPath(abs, env);
279
+ const canon = await canonicalPath(abs, env, remaining(), signal);
255
280
  if (!projectRootCanon || !canon || !canon.startsWith(`${projectRootCanon}/`)) {
256
281
  allSafe = false;
257
282
  }
@@ -321,7 +346,13 @@ export async function runHook(ctx) {
321
346
  try {
322
347
  const run = (async () => {
323
348
  if (ctx && ctx.signal && ctx.signal.aborted) return TIMEOUT_REFUSAL;
324
- return evaluate({ command: extracted.command, projectRoot, env });
349
+ return evaluate({
350
+ command: extracted.command,
351
+ projectRoot,
352
+ env,
353
+ deadlineMs,
354
+ signal: ctx && ctx.signal,
355
+ });
325
356
  })();
326
357
  return await Promise.race([run, timeout]);
327
358
  } catch {
@@ -366,7 +397,7 @@ async function main() {
366
397
  try {
367
398
  const extracted = extractCommand({ rawInput, env });
368
399
  const command = extracted.unparseable ? '' : extracted.command;
369
- const verdict = evaluate({ command, projectRoot, env });
400
+ const verdict = await evaluate({ command, projectRoot, env, deadlineMs: HOOK_DEADLINE_MS });
370
401
  if (verdict.stdout) process.stdout.write(verdict.stdout);
371
402
  if (verdict.stderr) process.stderr.write(verdict.stderr);
372
403
  process.exit(verdict.code);
@@ -1,7 +1,8 @@
1
1
  #!/bin/bash
2
+ set -euo pipefail
2
3
 
3
4
  # Script khởi tạo docs cho project mới
4
- # Usage: ./init-project-docs.sh "Project Name"
5
+ # Usage: ./init-project-docs.sh "Project Name" [author]
5
6
 
6
7
  PROJECT_NAME=${1:-"New Project"}
7
8
  AUTHOR=${2:-$(git config user.name 2>/dev/null || echo "Developer")}
@@ -12,40 +13,82 @@ echo "📅 Date: $DATE"
12
13
  echo "👤 Author: $AUTHOR"
13
14
  echo ""
14
15
 
16
+ # C80-19: verify every template exists BEFORE touching ./docs — previously a
17
+ # missing template + redirect produced empty files while `rm -rf ./docs` had
18
+ # already destroyed the old docs (data loss).
19
+ SKILL_DIR="$HOME/.claude/skills/docs-manager/templates"
20
+ TEMPLATES=(
21
+ "README.md.template:docs/README.md"
22
+ "project.md.template:docs/project.md"
23
+ "memory.md.template:docs/memory.md"
24
+ "agent-roles.md.template:docs/agents/agent-roles.md"
25
+ "coding-conventions.md.template:docs/standards/coding-conventions.md"
26
+ "onboarding.md.template:docs/guides/onboarding.md"
27
+ )
28
+ missing=0
29
+ for pair in "${TEMPLATES[@]}"; do
30
+ tpl="${pair%%:*}"
31
+ if [ ! -f "$SKILL_DIR/$tpl" ]; then
32
+ echo "❌ Missing template: $SKILL_DIR/$tpl" >&2
33
+ missing=1
34
+ fi
35
+ done
36
+ if [ "$missing" -ne 0 ]; then
37
+ echo "❌ Templates incomplete — aborting without touching docs/" >&2
38
+ exit 1
39
+ fi
40
+
15
41
  # Kiểm tra đã có docs/ chưa
16
42
  if [ -d "./docs" ]; then
17
43
  echo "⚠️ Warning: docs/ folder already exists!"
18
- read -p "Overwrite? (y/N): " -n 1 -r
44
+ read -p "Overwrite? (y/N): " -n 1 -r || true
19
45
  echo
20
- if [[ ! $REPLY =~ ^[Yy]$ ]]; then
46
+ if [[ ! ${REPLY:-} =~ ^[Yy]$ ]]; then
21
47
  echo "❌ Cancelled"
22
48
  exit 1
23
49
  fi
24
- rm -rf ./docs
50
+ # C80-19: preserve the old tree by renaming it BEFORE any write — the
51
+ # previous script ran `rm -rf ./docs` up front, so a failure destroyed docs.
52
+ backup_dir="./docs.bak-$(date +%Y%m%d-%H%M%S)"
53
+ mv "./docs" "$backup_dir"
54
+ echo "🗂️ Previous docs preserved at $backup_dir (remove manually when satisfied)"
25
55
  fi
26
56
 
27
57
  # Tạo cấu trúc
28
58
  echo "📁 Creating folder structure..."
29
59
  mkdir -p docs/{architecture,agents/prompts,standards,workflows,context,guides,models,integrations}
30
60
 
31
- # Copy templates
32
- SKILL_DIR="$HOME/.claude/skills/docs-manager/templates"
33
-
34
61
  echo "📄 Creating files from templates..."
35
62
 
36
- # Function replace placeholders
63
+ # Escape replacement text for sed (&, \, |, /, newlines) — C80-19: an
64
+ # unescaped metachar in PROJECT_NAME/AUTHOR corrupted output or broke sed.
65
+ sed_escape() {
66
+ printf '%s' "$1" | sed -e 's/[&|\\\/]/\\&/g'
67
+ }
68
+ PROJECT_NAME_E=$(sed_escape "$PROJECT_NAME")
69
+ AUTHOR_E=$(sed_escape "$AUTHOR")
70
+ DATE_E=$(sed_escape "$DATE")
71
+
37
72
  replace_placeholders() {
38
73
  local file=$1
39
- sed "s/{{PROJECT_NAME}}/$PROJECT_NAME/g; s/{{AUTHOR}}/$AUTHOR/g; s/{{DATE}}/$DATE/g" "$file"
74
+ sed "s/{{PROJECT_NAME}}/$PROJECT_NAME_E/g; s/{{AUTHOR}}/$AUTHOR_E/g; s/{{DATE}}/$DATE_E/g" "$file"
75
+ }
76
+
77
+ # Write to a temp file first; only install on success so a sed failure never
78
+ # leaves an empty/partial doc.
79
+ gen_doc() {
80
+ local tpl=$1 dest=$2
81
+ local tmp
82
+ tmp=$(mktemp)
83
+ replace_placeholders "$SKILL_DIR/$tpl" > "$tmp"
84
+ mv "$tmp" "$dest"
40
85
  }
41
86
 
42
- # Copy và replace
43
- replace_placeholders "$SKILL_DIR/README.md.template" > docs/README.md
44
- replace_placeholders "$SKILL_DIR/project.md.template" > docs/project.md
45
- replace_placeholders "$SKILL_DIR/memory.md.template" > docs/memory.md
46
- replace_placeholders "$SKILL_DIR/agent-roles.md.template" > docs/agents/agent-roles.md
47
- replace_placeholders "$SKILL_DIR/coding-conventions.md.template" > docs/standards/coding-conventions.md
48
- replace_placeholders "$SKILL_DIR/onboarding.md.template" > docs/guides/onboarding.md
87
+ for pair in "${TEMPLATES[@]}"; do
88
+ tpl="${pair%%:*}"
89
+ dest="${pair#*:}"
90
+ gen_doc "$tpl" "$dest"
91
+ done
49
92
 
50
93
  # Tạo empty files
51
94
  echo "📝 Creating placeholder files..."
@@ -639,11 +639,17 @@ class Document:
639
639
  # Create temporary directory with subdirectories for unpacked content and baseline
640
640
  self.temp_dir = tempfile.mkdtemp(prefix="docx_")
641
641
  self.unpacked_path = Path(self.temp_dir) / "unpacked"
642
- shutil.copytree(self.original_path, self.unpacked_path)
643
-
644
- # Pack original directory into temporary .docx for validation baseline (outside unpacked dir)
645
- self.original_docx = Path(self.temp_dir) / "original.docx"
646
- pack_document(self.original_path, self.original_docx, validate=False)
642
+ try:
643
+ shutil.copytree(self.original_path, self.unpacked_path)
644
+
645
+ # Pack original directory into temporary .docx for validation baseline (outside unpacked dir)
646
+ self.original_docx = Path(self.temp_dir) / "original.docx"
647
+ pack_document(self.original_path, self.original_docx, validate=False)
648
+ except Exception:
649
+ # C80-20: a failed constructor must not rely on __del__ timing —
650
+ # clean the temp dir right here before the exception propagates.
651
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
652
+ raise
647
653
 
648
654
  self.word_path = self.unpacked_path / "word"
649
655
 
@@ -830,10 +836,27 @@ class Document:
830
836
  self.next_comment_id += 1
831
837
  return comment_id
832
838
 
839
+ def close(self):
840
+ """Release the temp working tree (C80-20: deterministic, not __del__)."""
841
+ temp_dir = getattr(self, "temp_dir", None)
842
+ if temp_dir and Path(temp_dir).exists():
843
+ shutil.rmtree(temp_dir, ignore_errors=True)
844
+ self.temp_dir = None
845
+
846
+ def __enter__(self):
847
+ return self
848
+
849
+ def __exit__(self, exc_type, exc, tb):
850
+ self.close()
851
+ return False
852
+
833
853
  def __del__(self):
834
- """Clean up temporary directory on deletion."""
835
- if hasattr(self, "temp_dir") and Path(self.temp_dir).exists():
836
- shutil.rmtree(self.temp_dir)
854
+ # Safety net only — callers should use `with` or close() explicitly;
855
+ # __del__ timing is non-deterministic (GC cycles, interpreter kill).
856
+ try:
857
+ self.close()
858
+ except Exception:
859
+ pass
837
860
 
838
861
  def validate(self) -> None:
839
862
  """
@@ -73,6 +73,10 @@ export const addDataIDB = async (storage, data) => {
73
73
  return new Promise((resolve, reject) => {
74
74
  try{
75
75
  let transaction = db.transaction([storage], 'readwrite');
76
+ // C80-16: transaction abort must settle the outer promise.
77
+ transaction.onerror = () => {
78
+ reject('Error writing to object store (transaction failed)');
79
+ };
76
80
  let objectStore = transaction.objectStore(storage);
77
81
  let clearRequest = objectStore.clear();
78
82
 
@@ -89,9 +93,13 @@ export const addDataIDB = async (storage, data) => {
89
93
  resolve(); // Resolve immediately if no data is provided
90
94
  }
91
95
  }
96
+ clearRequest.onerror = () => {
97
+ reject('Error clearing object store');
98
+ };
92
99
  } catch (e) {
93
100
  window.indexedDB.deleteDatabase(db_state.DB_NAME);
94
101
  createStoreIDB(storage);
102
+ reject(e); // C80-16: never leave the await hanging
95
103
  }
96
104
 
97
105
  });
@@ -114,23 +122,36 @@ export const getDataIDB = async (storage) => {
114
122
  return;
115
123
  }
116
124
  let trans = db.transaction([storage],'readonly');
125
+ let store = trans.objectStore(storage);
126
+ let to_return = [];
127
+
117
128
  trans.oncomplete = e => {
118
129
  resolve(to_return[0]);
119
130
  };
120
-
121
- let store = trans.objectStore(storage);
122
- let to_return = [];
123
-
124
- store.openCursor().onsuccess = e => {
131
+ // C80-15: a failed transaction/cursor must settle the promise —
132
+ // previously the outer await hung forever.
133
+ trans.onerror = () => {
134
+ resolve([]);
135
+ };
136
+ trans.onabort = () => {
137
+ resolve([]);
138
+ };
139
+
140
+ const cursorRequest = store.openCursor();
141
+ cursorRequest.onsuccess = e => {
125
142
  let cursor = e.target.result;
126
143
  if (cursor) {
127
144
  to_return.push(cursor.value)
128
145
  cursor.continue();
129
146
  }
130
147
  };
148
+ cursorRequest.onerror = () => {
149
+ resolve(to_return[0] ?? []);
150
+ };
131
151
  } catch (e) {
132
152
  window.indexedDB.deleteDatabase(db_state.DB_NAME);
133
153
  createStoreIDB(storage);
154
+ resolve([]); // C80-15: never leave the await hanging
134
155
  }
135
156
  });
136
157
  };
@@ -70,10 +70,6 @@ export const request_origin = async (url, data = {}, method = "post") => {
70
70
  state.loading = true;
71
71
  }
72
72
  let response = await ax[method.toLowerCase()](url, data);
73
- state.loading_count -= 1;
74
- if (state.loading_count === 0) {
75
- state.loading = false;
76
- }
77
73
  if(typeof response?.data === 'object'){
78
74
  return response?.data;
79
75
  } else {
@@ -82,6 +78,14 @@ export const request_origin = async (url, data = {}, method = "post") => {
82
78
  }
83
79
  } catch {
84
80
  return [];
81
+ } finally {
82
+ // C80-11: decrement on every path — a thrown request must not wedge the
83
+ // global loading spinner forever.
84
+ state.loading_count -= 1;
85
+ if (state.loading_count <= 0) {
86
+ state.loading_count = 0;
87
+ state.loading = false;
88
+ }
85
89
  }
86
90
  };
87
91
  /**
@@ -128,15 +132,19 @@ export const request = async (url, data = {}, method = "post", with_loading=true
128
132
  const gzipBuffer = decompressData(response?.data);
129
133
  to_return = JSON.parse(gzipBuffer);
130
134
  }
135
+ return to_return;
136
+ } catch {
137
+ return [];
138
+ } finally {
139
+ // C80-09: decrement on every path — a thrown request must not wedge the
140
+ // global loading spinner forever.
131
141
  if(with_loading){
132
142
  state.loading_count -= 1;
133
- if (state.loading_count === 0) {
143
+ if (state.loading_count <= 0) {
144
+ state.loading_count = 0;
134
145
  state.loading = false;
135
146
  }
136
147
  }
137
- return to_return;
138
- } catch {
139
- return [];
140
148
  }
141
149
  };
142
150
 
@@ -174,15 +182,19 @@ export const requestForm = async (url, data = {}, method = "post", with_loading=
174
182
  const gzipBuffer = decompressData(response?.data);
175
183
  to_return = JSON.parse(gzipBuffer);
176
184
  }
185
+ return to_return;
186
+ } catch {
187
+ return [];
188
+ } finally {
189
+ // C80-10: decrement on every path — a thrown request must not wedge the
190
+ // global loading spinner forever.
177
191
  if(with_loading){
178
192
  state.loading_count -= 1;
179
- if (state.loading_count === 0) {
193
+ if (state.loading_count <= 0) {
194
+ state.loading_count = 0;
180
195
  state.loading = false;
181
196
  }
182
197
  }
183
- return to_return;
184
- } catch {
185
- return [];
186
198
  }
187
199
  };
188
200
 
@@ -120,19 +120,24 @@ export const getSession = (key) => {
120
120
  if (process.client) {
121
121
  const storedSession = localStorage.getItem(SESSION_KEY)
122
122
  if (storedSession) {
123
- const { data, expiry, hash } = JSON.parse(storedSession)
124
- // check hash
125
- const check_hash = encrypt_string(expiry.toString());
126
- if (hash !== check_hash) {
127
- clearSession();
128
- return undefined;
129
- } else {
130
- if (expiry > Date.now()) {
131
- session.value = data
132
- return session.value[key]
123
+ try {
124
+ const { data, expiry, hash } = JSON.parse(storedSession)
125
+ // check hash — C80-14: non-JSON or a blob missing expiry/hash must
126
+ // clear + return undefined, not throw into page guards.
127
+ const check_hash = encrypt_string(expiry.toString());
128
+ if (hash !== check_hash) {
129
+ clearSession();
130
+ return undefined;
133
131
  } else {
134
- clearSession()
132
+ if (expiry > Date.now()) {
133
+ session.value = data
134
+ return session.value[key]
135
+ } else {
136
+ clearSession()
137
+ }
135
138
  }
139
+ } catch {
140
+ clearSession()
136
141
  }
137
142
  }
138
143
  }