redmine-context 1.0.0

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 (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Lista compacta em Markdown dos resultados da tool `search_issues` (M1-13).
3
+ *
4
+ * Diferente do bundle completo de uma issue (#16), aqui cada item é UMA linha:
5
+ * id + status + responsável (metadados estruturais, fora da fence) e o assunto
6
+ * isolado numa fence `<untrusted-content>` — reutilizando {@link fenceInline}
7
+ * para manter a MESMA marcação anti prompt-injection do bundle.
8
+ *
9
+ * Determinístico: nenhum timestamp; a ordem é a recebida do chamador (que já
10
+ * reflete a ordenação do Redmine). Avisos de degradação (ex.: `/search`
11
+ * indisponível) são renderizados no corpo, atendendo ao "aviso no payload".
12
+ */
13
+ import { fenceInline } from './markdown.js';
14
+ /** Placeholder para assunto ausente na página de listagem. */
15
+ const ABSENT_SUBJECT = '_(sem assunto)_';
16
+ /** Renderiza uma linha compacta de resultado. */
17
+ function renderItem(item) {
18
+ const subject = item.subject === null ? ABSENT_SUBJECT : fenceInline(item.subject);
19
+ return `- **#${item.id}** — status: ${item.status} — responsável: ${item.assignee} — ${subject}`;
20
+ }
21
+ /**
22
+ * Renderiza a lista compacta de resultados de busca em Markdown.
23
+ *
24
+ * @param items - Itens já resolvidos (status/responsável em nomes legíveis).
25
+ * @param meta - Consulta e avisos de degradação (ver {@link SearchListMeta}).
26
+ * @returns Documento Markdown terminado por uma quebra de linha.
27
+ * @example
28
+ * const md = buildSearchListMarkdown(
29
+ * [{ id: 1, subject: 'x', status: 'New', assignee: '(nenhum)' }],
30
+ * { query: 'x' },
31
+ * );
32
+ */
33
+ export function buildSearchListMarkdown(items, meta = {}) {
34
+ const lines = [`# Resultados da busca (${items.length})`, ''];
35
+ if (meta.query !== undefined && meta.query.length > 0) {
36
+ // Consulta é entrada arbitrária do chamador: também vai em fence untrusted.
37
+ lines.push(`Consulta: ${fenceInline(meta.query)}`, '');
38
+ }
39
+ const warnings = meta.warnings ?? [];
40
+ for (const warning of warnings) {
41
+ lines.push(`> Aviso: ${warning}`);
42
+ }
43
+ if (warnings.length > 0)
44
+ lines.push('');
45
+ if (items.length === 0) {
46
+ lines.push('_(nenhum resultado)_');
47
+ }
48
+ else {
49
+ for (const item of items)
50
+ lines.push(renderItem(item));
51
+ }
52
+ return `${lines.join('\n')}\n`;
53
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Serializador JSON estável para o bundle determinístico (issue #15).
3
+ *
4
+ * O contrato do bundle exige que dois empacotamentos do mesmo estado sejam
5
+ * byte-idênticos. `JSON.stringify` NÃO garante ordem de chaves entre objetos
6
+ * construídos por caminhos diferentes; este módulo remove essa variável
7
+ * reordenando as chaves de todo objeto (recursivamente) antes de serializar.
8
+ * Arrays têm sua ordem preservada — a ordenação semântica das coleções é
9
+ * responsabilidade de quem monta o corpo (`./json.ts`), não do serializador.
10
+ *
11
+ * Sem dependências novas: apenas `JSON.stringify` sobre uma cópia com chaves
12
+ * ordenadas.
13
+ */
14
+ /** Valor serializável em JSON (fechado sobre si mesmo, recursivo). */
15
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
16
+ [key: string]: JsonValue;
17
+ };
18
+ /**
19
+ * Serializa um valor em JSON estável (chaves ordenadas, indentação de 2 espaços).
20
+ *
21
+ * @param value - Valor JSON a serializar.
22
+ * @returns String JSON determinística — idêntica para valores semanticamente iguais.
23
+ * @example
24
+ * stableStringify({ b: 1, a: 2 }); // '{\n "a": 2,\n "b": 1\n}'
25
+ */
26
+ export declare function stableStringify(value: JsonValue): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Serializador JSON estável para o bundle determinístico (issue #15).
3
+ *
4
+ * O contrato do bundle exige que dois empacotamentos do mesmo estado sejam
5
+ * byte-idênticos. `JSON.stringify` NÃO garante ordem de chaves entre objetos
6
+ * construídos por caminhos diferentes; este módulo remove essa variável
7
+ * reordenando as chaves de todo objeto (recursivamente) antes de serializar.
8
+ * Arrays têm sua ordem preservada — a ordenação semântica das coleções é
9
+ * responsabilidade de quem monta o corpo (`./json.ts`), não do serializador.
10
+ *
11
+ * Sem dependências novas: apenas `JSON.stringify` sobre uma cópia com chaves
12
+ * ordenadas.
13
+ */
14
+ /**
15
+ * Reordena as chaves de todo objeto alfabeticamente, em profundidade.
16
+ *
17
+ * Propriedades `undefined` são descartadas (mesma semântica de `JSON.stringify`),
18
+ * evitando que a presença/ausência de chaves opcionais dependa do caminho de
19
+ * construção. A ordem de arrays é mantida intacta.
20
+ *
21
+ * @param value - Valor a normalizar.
22
+ * @returns Cópia com chaves de objeto ordenadas recursivamente.
23
+ */
24
+ function sortKeysDeep(value) {
25
+ if (Array.isArray(value)) {
26
+ return value.map(sortKeysDeep);
27
+ }
28
+ if (value !== null && typeof value === 'object') {
29
+ const source = value;
30
+ const sorted = {};
31
+ for (const key of Object.keys(source).sort()) {
32
+ const entry = source[key];
33
+ if (entry !== undefined)
34
+ sorted[key] = sortKeysDeep(entry);
35
+ }
36
+ return sorted;
37
+ }
38
+ return value;
39
+ }
40
+ /**
41
+ * Serializa um valor em JSON estável (chaves ordenadas, indentação de 2 espaços).
42
+ *
43
+ * @param value - Valor JSON a serializar.
44
+ * @returns String JSON determinística — idêntica para valores semanticamente iguais.
45
+ * @example
46
+ * stableStringify({ b: 1, a: 2 }); // '{\n "a": 2,\n "b": 1\n}'
47
+ */
48
+ export function stableStringify(value) {
49
+ return JSON.stringify(sortKeysDeep(value), null, 2);
50
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Contrato do módulo Cache (M3-01, ADR-004).
3
+ *
4
+ * Define os TIPOS DE CHAVE das duas camadas do cache local e a interface
5
+ * {@link CacheStore} — comum a qualquer implementação (memória nesta issue,
6
+ * disco nas seguintes). O acoplamento entre as camadas se dá exclusivamente por
7
+ * estes tipos e por uma função de SERIALIZAÇÃO ESTÁVEL de chave
8
+ * ({@link serializeCacheKey}), de modo que a suíte de contrato possa exercitar
9
+ * qualquer implementação sem conhecer seus detalhes.
10
+ *
11
+ * Camadas (ADR-004):
12
+ * - attachment (extrações caras): chave
13
+ * `(instance_hash, attachment_id, digest, extractor_version + model + params)`.
14
+ * Trocar o modelo/versão do extrator invalida corretamente; comentários novos
15
+ * na issue NUNCA invalidam a extração de um anexo imutável.
16
+ * - issue (bundle/metadados baratos): chave `(issue_id, updated_on)`.
17
+ *
18
+ * O `instance_hash` isola instâncias Redmine distintas: é o SHA-256 truncado da
19
+ * URL base normalizada ({@link instanceHash}), reusando `normalizeInstanceUrl`
20
+ * (ADR-003) para que formas equivalentes da mesma URL colapsem numa só chave.
21
+ */
22
+ import type { Logger } from '../client/index.js';
23
+ /**
24
+ * Parâmetros do extrator que participam da identidade da extração. São valores
25
+ * escalares (ex.: `{ language: 'pt', beam_size: 5 }`); a serialização ordena as
26
+ * chaves para ser estável independentemente da ordem de inserção.
27
+ */
28
+ export type ExtractorParams = Record<string, string | number | boolean>;
29
+ /**
30
+ * Chave da camada de attachment (extrações caras, ADR-004). Identifica de forma
31
+ * única a extração de UM anexo por UMA configuração de extrator. `digest` é o
32
+ * hash do conteúdo do anexo (imutável no Redmine).
33
+ */
34
+ export interface AttachmentCacheKey {
35
+ kind: 'attachment';
36
+ /** Isolamento por instância — ver {@link instanceHash}. */
37
+ instanceHash: string;
38
+ /** `attachment.id` do Redmine (único apenas dentro da instância). */
39
+ attachmentId: number;
40
+ /** Digest do conteúdo do anexo (ADR-004: `attachment.digest`). */
41
+ digest: string;
42
+ /** Versão do extrator; trocá-la invalida a extração. */
43
+ extractorVersion: string;
44
+ /** Modelo usado (ex.: `whisper-large-v3`); trocá-lo invalida a extração. */
45
+ model: string;
46
+ /** Parâmetros escalares do extrator — ver {@link ExtractorParams}. */
47
+ params: ExtractorParams;
48
+ }
49
+ /**
50
+ * Chave da camada de issue (bundle/metadados baratos, ADR-004). `updated_on`
51
+ * muda a cada edição da issue; o rebuild reutiliza 100% das extrações da camada
52
+ * de baixo (que não dependem de `updated_on`).
53
+ */
54
+ export interface IssueCacheKey {
55
+ kind: 'issue';
56
+ /** `issue.id` do Redmine. */
57
+ issueId: number;
58
+ /** `issue.updated_on` (timestamp ISO) — muda a cada edição. */
59
+ updatedOn: string;
60
+ }
61
+ /** União discriminada das chaves das duas camadas do cache (ADR-004). */
62
+ export type CacheKey = AttachmentCacheKey | IssueCacheKey;
63
+ /** Contexto passado ao {@link GcHook} quando o GC é acionado. */
64
+ export interface GcContext {
65
+ /** Origem do acionamento: após um `put` ou uma chamada manual a `gc()`. */
66
+ reason: 'put' | 'manual';
67
+ /** Número de entradas atualmente no store no momento do acionamento. */
68
+ entryCount: number;
69
+ }
70
+ /**
71
+ * Hook de garbage collection (ADR-004: LRU com quota). Ponto de extensão comum:
72
+ * a implementação em disco delega a ele a decisão de despejo; a de memória
73
+ * apenas o notifica. Pode ser assíncrono.
74
+ */
75
+ export type GcHook = (context: GcContext) => void | Promise<void>;
76
+ /** Opções comuns a qualquer implementação de {@link CacheStore}. */
77
+ export interface CacheStoreOptions {
78
+ /** Logger para avisos (ex.: lock estagnado); default no-op. Nunca `console.*`. */
79
+ logger?: Logger;
80
+ /** Hook de GC acionado após `put` e por `gc()` — ver {@link GcHook}. */
81
+ onGc?: GcHook;
82
+ /**
83
+ * TTL (ms) de um lock considerado estagnado: um segundo pretendente da MESMA
84
+ * chave que espere além deste teto assume que o detentor morreu e prossegue
85
+ * (evita deadlock permanente). `0` desabilita o teto (espera indefinida).
86
+ * Default: {@link DEFAULT_STALE_LOCK_TTL_MS}.
87
+ */
88
+ staleLockTtlMs?: number;
89
+ }
90
+ /**
91
+ * Contrato de armazenamento do cache local (ADR-004). Qualquer implementação
92
+ * (memória, disco) o satisfaz; a suíte de contrato (`runCacheContractSuite`)
93
+ * valida o comportamento observável, não a implementação.
94
+ *
95
+ * @typeParam V - Tipo do valor cacheado (ex.: extração, bundle).
96
+ */
97
+ export interface CacheStore<V = unknown> {
98
+ /**
99
+ * Recupera o valor de uma chave, ou `undefined` se ausente/invalidado.
100
+ * @param key - Chave de qualquer camada.
101
+ */
102
+ get(key: CacheKey): Promise<V | undefined>;
103
+ /**
104
+ * Grava (ou substitui) o valor de uma chave. Aciona o {@link GcHook} com
105
+ * `reason: 'put'` após persistir.
106
+ * @param key - Chave de qualquer camada.
107
+ * @param value - Valor a armazenar.
108
+ */
109
+ put(key: CacheKey, value: V): Promise<void>;
110
+ /**
111
+ * Remove a entrada de uma chave; no-op se não existir.
112
+ * @param key - Chave de qualquer camada.
113
+ */
114
+ invalidate(key: CacheKey): Promise<void>;
115
+ /**
116
+ * Executa `critical` em exclusão mútua POR CHAVE: uma segunda aquisição da
117
+ * mesma chave espera a primeira liberar; chaves distintas não se bloqueiam. O
118
+ * lock é sempre liberado ao final, inclusive se `critical` lançar.
119
+ *
120
+ * @typeParam T - Tipo de retorno da seção crítica.
121
+ * @param key - Chave que serializa o acesso.
122
+ * @param critical - Função executada sob o lock.
123
+ * @returns O resultado de `critical`.
124
+ */
125
+ lock<T>(key: CacheKey, critical: () => Promise<T>): Promise<T>;
126
+ /**
127
+ * Aciona o {@link GcHook} manualmente (`reason: 'manual'`). No-op se nenhum
128
+ * hook foi configurado.
129
+ */
130
+ gc(): Promise<void>;
131
+ }
132
+ /** TTL default (ms) para considerar um lock estagnado. */
133
+ export declare const DEFAULT_STALE_LOCK_TTL_MS = 30000;
134
+ /**
135
+ * Calcula o `instance_hash` de uma URL base do Redmine (ADR-004): SHA-256
136
+ * truncado da URL normalizada por `normalizeInstanceUrl` (ADR-003), garantindo
137
+ * que formas equivalentes (maiúsculas, porta padrão, barra final) colapsem no
138
+ * mesmo hash e que instâncias distintas nunca colidam.
139
+ *
140
+ * @param instanceUrl - URL base da instância (ex.: `https://Redmine.Example/`).
141
+ * @returns Hash hex truncado (16 chars = 64 bits).
142
+ * @throws {TypeError} Se `instanceUrl` não for uma URL válida.
143
+ * @example
144
+ * instanceHash('https://redmine.example'); // ex.: 'a1b2c3d4e5f60718'
145
+ */
146
+ export declare function instanceHash(instanceUrl: string): string;
147
+ /**
148
+ * Serializa uma {@link CacheKey} numa string ESTÁVEL e livre de colisão entre
149
+ * camadas (prefixo `att`/`iss`) e entre campos (separador NUL). É a fronteira
150
+ * usada por qualquer implementação para indexar entradas e locks.
151
+ *
152
+ * @param key - Chave de qualquer camada.
153
+ * @returns String determinística que identifica a chave.
154
+ * @example
155
+ * serializeCacheKey({ kind: 'issue', issueId: 42, updatedOn: '2026-07-20T00:00:00Z' });
156
+ */
157
+ export declare function serializeCacheKey(key: CacheKey): string;
Binary file
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Índice por instância do {@link ../cache/disk.js DiskCacheStore} (M3-05.1,
3
+ * ADR-004): `<cache_dir>/<instance_hash>/index.json` registra, por entrada da
4
+ * camada de ATTACHMENT, a chave serializada, o tamanho em bytes, o tipo
5
+ * (`original` | `extraction`) e o `last_accessed_at` — é o insumo do GC/LRU
6
+ * (#47). Entradas issue-level NÃO são indexadas (baratas de reconstruir).
7
+ *
8
+ * Semântica de escrita:
9
+ * - `recordPut`/`remove` persistem imediatamente (write-then-rename atômico);
10
+ * - `recordAccess` (get) só atualiza um BUFFER em memória — a janela de perda
11
+ * (acessos entre o último flush e um crash) é aceita: o pior caso é o LRU
12
+ * enxergar um item como "mais antigo" do que realmente é. O flush acontece
13
+ * no próximo `recordPut`/`remove`/`flushAll` (chamado pelo gc()).
14
+ *
15
+ * Resiliência: índice corrompido ou ausente é RECONSTRUÍDO varrendo os
16
+ * arquivos de valor da instância. As entradas do registro são chaveadas pelo
17
+ * NOME DO ARQUIVO de valor (sha256 da chave serializada) — assim a
18
+ * reconstrução (que não consegue reverter o hash para a chave) e as operações
19
+ * normais colidem no MESMO registro, sem duplicar; `entry.key` guarda a chave
20
+ * serializada quando conhecida (após reconstrução, o nome do arquivo fica como
21
+ * valor opaco até o próximo put daquela chave).
22
+ */
23
+ /** Nome do arquivo de índice, ao lado dos diretórios de anexo da instância. */
24
+ export declare const INDEX_FILE_NAME = "index.json";
25
+ /** Tipo de uma entrada indexada — quotas separadas no GC (#47). */
26
+ export type CacheEntryType = 'original' | 'extraction';
27
+ /** Uma entrada do índice por instância. */
28
+ export interface CacheIndexEntry {
29
+ /** Chave serializada (opaca — nome do arquivo — após reconstrução). */
30
+ key: string;
31
+ /** Tamanho do valor em bytes, como gravado em disco. */
32
+ size: number;
33
+ /** Classificação para as quotas do GC. */
34
+ type: CacheEntryType;
35
+ /** ISO 8601 do último acesso conhecido (ver janela de perda no topo). */
36
+ lastAccessedAt: string;
37
+ }
38
+ /** Logger mínimo (padrão do repo). */
39
+ interface IndexLogger {
40
+ warn(message: string): void;
41
+ }
42
+ /**
43
+ * Gerência dos índices por instância de um `DiskCacheStore`.
44
+ * Uma instância desta classe pertence a UM store (mesmo `root`).
45
+ */
46
+ export declare class DiskCacheIndex {
47
+ private readonly root;
48
+ private readonly logger;
49
+ private readonly instances;
50
+ constructor(root: string, logger?: IndexLogger);
51
+ /**
52
+ * Registra (ou substitui) a entrada de um put — persiste imediatamente.
53
+ * @returns O tamanho anterior da entrada substituída (0 se era nova) — usado
54
+ * pelo total incremental do GC (review #137).
55
+ */
56
+ recordPut(instanceHash: string, recordKey: string, entry: CacheIndexEntry): Promise<number>;
57
+ /** Bufferiza a atualização de `last_accessed_at` de um get (throttled). */
58
+ recordAccess(instanceHash: string, recordKey: string): void;
59
+ /**
60
+ * Remove a entrada de um invalidate — persiste imediatamente.
61
+ * @returns O tamanho da entrada removida (0 se não existia).
62
+ */
63
+ remove(instanceHash: string, recordKey: string): Promise<number>;
64
+ /** Faz flush de TODOS os buffers pendentes (chamado pelo gc()). */
65
+ flushAll(): Promise<void>;
66
+ /**
67
+ * Reconcilia o índice de uma instância com o DISCO: adiciona entradas para
68
+ * arquivos órfãos (crash entre o rename do valor e o recordPut — review
69
+ * #135) sem tocar nas entradas já conhecidas. Chamado pelo gc().
70
+ */
71
+ reconcile(instanceHash: string): Promise<void>;
72
+ /** Entradas atuais de uma instância (para o GC/LRU do #47). */
73
+ entriesOf(instanceHash: string): Promise<ReadonlyMap<string, CacheIndexEntry>>;
74
+ private ensure;
75
+ /** Carrega (lazy) o índice de uma instância, reconstruindo se necessário. */
76
+ private load;
77
+ /** Reconstrói o índice varrendo os arquivos de valor da instância. */
78
+ private rebuild;
79
+ /** Persiste o índice de uma instância (aplicando o buffer de acessos). */
80
+ private persist;
81
+ }
82
+ export {};
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Índice por instância do {@link ../cache/disk.js DiskCacheStore} (M3-05.1,
3
+ * ADR-004): `<cache_dir>/<instance_hash>/index.json` registra, por entrada da
4
+ * camada de ATTACHMENT, a chave serializada, o tamanho em bytes, o tipo
5
+ * (`original` | `extraction`) e o `last_accessed_at` — é o insumo do GC/LRU
6
+ * (#47). Entradas issue-level NÃO são indexadas (baratas de reconstruir).
7
+ *
8
+ * Semântica de escrita:
9
+ * - `recordPut`/`remove` persistem imediatamente (write-then-rename atômico);
10
+ * - `recordAccess` (get) só atualiza um BUFFER em memória — a janela de perda
11
+ * (acessos entre o último flush e um crash) é aceita: o pior caso é o LRU
12
+ * enxergar um item como "mais antigo" do que realmente é. O flush acontece
13
+ * no próximo `recordPut`/`remove`/`flushAll` (chamado pelo gc()).
14
+ *
15
+ * Resiliência: índice corrompido ou ausente é RECONSTRUÍDO varrendo os
16
+ * arquivos de valor da instância. As entradas do registro são chaveadas pelo
17
+ * NOME DO ARQUIVO de valor (sha256 da chave serializada) — assim a
18
+ * reconstrução (que não consegue reverter o hash para a chave) e as operações
19
+ * normais colidem no MESMO registro, sem duplicar; `entry.key` guarda a chave
20
+ * serializada quando conhecida (após reconstrução, o nome do arquivo fica como
21
+ * valor opaco até o próximo put daquela chave).
22
+ */
23
+ import { readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
24
+ import { randomBytes } from 'node:crypto';
25
+ import { join } from 'node:path';
26
+ /** Nome do arquivo de índice, ao lado dos diretórios de anexo da instância. */
27
+ export const INDEX_FILE_NAME = 'index.json';
28
+ /** Versão do schema do índice (evolução futura sem quebrar leitores). */
29
+ const INDEX_VERSION = 1;
30
+ /**
31
+ * Gerência dos índices por instância de um `DiskCacheStore`.
32
+ * Uma instância desta classe pertence a UM store (mesmo `root`).
33
+ */
34
+ export class DiskCacheIndex {
35
+ root;
36
+ logger;
37
+ instances = new Map();
38
+ constructor(root, logger = { warn: () => undefined }) {
39
+ this.root = root;
40
+ this.logger = logger;
41
+ }
42
+ /**
43
+ * Registra (ou substitui) a entrada de um put — persiste imediatamente.
44
+ * @returns O tamanho anterior da entrada substituída (0 se era nova) — usado
45
+ * pelo total incremental do GC (review #137).
46
+ */
47
+ async recordPut(instanceHash, recordKey, entry) {
48
+ const index = await this.load(instanceHash);
49
+ const previousSize = index.entries.get(recordKey)?.size ?? 0;
50
+ index.entries.set(recordKey, entry);
51
+ index.pendingAccess.delete(recordKey);
52
+ await this.persist(instanceHash, index);
53
+ return previousSize;
54
+ }
55
+ /** Bufferiza a atualização de `last_accessed_at` de um get (throttled). */
56
+ recordAccess(instanceHash, recordKey) {
57
+ const index = this.instances.get(instanceHash);
58
+ const at = new Date().toISOString();
59
+ if (index !== undefined && index.loaded) {
60
+ index.pendingAccess.set(recordKey, at);
61
+ return;
62
+ }
63
+ // Instância ainda não carregada: registra num índice lazy para o próximo
64
+ // flush carregar e mesclar.
65
+ const lazy = this.ensure(instanceHash);
66
+ lazy.pendingAccess.set(recordKey, at);
67
+ }
68
+ /**
69
+ * Remove a entrada de um invalidate — persiste imediatamente.
70
+ * @returns O tamanho da entrada removida (0 se não existia).
71
+ */
72
+ async remove(instanceHash, recordKey) {
73
+ const index = await this.load(instanceHash);
74
+ const removedSize = index.entries.get(recordKey)?.size ?? 0;
75
+ index.entries.delete(recordKey);
76
+ index.pendingAccess.delete(recordKey);
77
+ await this.persist(instanceHash, index);
78
+ return removedSize;
79
+ }
80
+ /** Faz flush de TODOS os buffers pendentes (chamado pelo gc()). */
81
+ async flushAll() {
82
+ for (const instanceHash of this.instances.keys()) {
83
+ const index = await this.load(instanceHash);
84
+ if (index.pendingAccess.size === 0) {
85
+ continue;
86
+ }
87
+ await this.persist(instanceHash, index);
88
+ }
89
+ }
90
+ /**
91
+ * Reconcilia o índice de uma instância com o DISCO: adiciona entradas para
92
+ * arquivos órfãos (crash entre o rename do valor e o recordPut — review
93
+ * #135) sem tocar nas entradas já conhecidas. Chamado pelo gc().
94
+ */
95
+ async reconcile(instanceHash) {
96
+ const index = await this.load(instanceHash);
97
+ const before = index.entries.size;
98
+ const scanned = { entries: new Map(), pendingAccess: new Map(), loaded: true };
99
+ await this.rebuild(instanceHash, scanned);
100
+ for (const [recordKey, entry] of scanned.entries) {
101
+ if (!index.entries.has(recordKey)) {
102
+ index.entries.set(recordKey, entry);
103
+ }
104
+ }
105
+ if (index.entries.size !== before) {
106
+ this.logger.warn(`cache: ${index.entries.size - before} entrada(s) órfã(s) reconciliada(s) em ${instanceHash}`);
107
+ await this.persist(instanceHash, index);
108
+ }
109
+ }
110
+ /** Entradas atuais de uma instância (para o GC/LRU do #47). */
111
+ async entriesOf(instanceHash) {
112
+ const index = await this.load(instanceHash);
113
+ return index.entries;
114
+ }
115
+ ensure(instanceHash) {
116
+ let index = this.instances.get(instanceHash);
117
+ if (index === undefined) {
118
+ index = { entries: new Map(), pendingAccess: new Map(), loaded: false };
119
+ this.instances.set(instanceHash, index);
120
+ }
121
+ return index;
122
+ }
123
+ /** Carrega (lazy) o índice de uma instância, reconstruindo se necessário. */
124
+ async load(instanceHash) {
125
+ const index = this.ensure(instanceHash);
126
+ if (index.loaded) {
127
+ return index;
128
+ }
129
+ const filePath = join(this.root, instanceHash, INDEX_FILE_NAME);
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(await readFile(filePath, 'utf8'));
133
+ }
134
+ catch (cause) {
135
+ if (cause.code !== 'ENOENT') {
136
+ this.logger.warn(`cache: índice corrompido em ${filePath} — reconstruindo do disco`);
137
+ }
138
+ parsed = undefined;
139
+ }
140
+ if (parsed !== undefined && typeof parsed === 'object' && parsed.entries !== undefined) {
141
+ for (const [recordKey, entry] of Object.entries(parsed.entries)) {
142
+ index.entries.set(recordKey, entry);
143
+ }
144
+ }
145
+ else {
146
+ await this.rebuild(instanceHash, index);
147
+ }
148
+ index.loaded = true;
149
+ return index;
150
+ }
151
+ /** Reconstrói o índice varrendo os arquivos de valor da instância. */
152
+ async rebuild(instanceHash, index) {
153
+ const attachmentsDir = join(this.root, instanceHash, 'attachments');
154
+ let dirs;
155
+ try {
156
+ dirs = await readdir(attachmentsDir);
157
+ }
158
+ catch {
159
+ return; // instância sem anexos: índice vazio.
160
+ }
161
+ const now = new Date().toISOString();
162
+ for (const dir of dirs) {
163
+ const full = join(attachmentsDir, dir);
164
+ let files;
165
+ try {
166
+ files = await readdir(full);
167
+ }
168
+ catch {
169
+ continue;
170
+ }
171
+ for (const file of files) {
172
+ if (!file.endsWith('.json') || file.endsWith('.tmp')) {
173
+ continue;
174
+ }
175
+ try {
176
+ const info = await stat(join(full, file));
177
+ index.entries.set(join(dir, file), {
178
+ // Irrecuperável a partir do hash: o caminho relativo fica como
179
+ // identificador opaco até o próximo put desta chave.
180
+ key: join(dir, file),
181
+ size: info.size,
182
+ type: 'extraction',
183
+ lastAccessedAt: now,
184
+ });
185
+ }
186
+ catch {
187
+ continue;
188
+ }
189
+ }
190
+ }
191
+ }
192
+ /** Persiste o índice de uma instância (aplicando o buffer de acessos). */
193
+ async persist(instanceHash, index) {
194
+ for (const [recordKey, at] of index.pendingAccess) {
195
+ const entry = index.entries.get(recordKey);
196
+ if (entry !== undefined) {
197
+ entry.lastAccessedAt = at;
198
+ }
199
+ }
200
+ index.pendingAccess.clear();
201
+ const filePath = join(this.root, instanceHash, INDEX_FILE_NAME);
202
+ const payload = {
203
+ version: INDEX_VERSION,
204
+ entries: Object.fromEntries(index.entries),
205
+ };
206
+ const tmpPath = `${filePath}.${randomBytes(6).toString('hex')}.tmp`;
207
+ try {
208
+ await writeFile(tmpPath, JSON.stringify(payload));
209
+ await rename(tmpPath, filePath);
210
+ }
211
+ catch (cause) {
212
+ await rm(tmpPath, { force: true });
213
+ // O diretório da instância pode não existir ainda (só acessos
214
+ // bufferizados, sem put) — nada a persistir nesse caso.
215
+ if (cause.code !== 'ENOENT') {
216
+ throw cause;
217
+ }
218
+ }
219
+ }
220
+ }