@payconductor/houndsman 0.3.0 → 0.5.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.
- package/README.md +25 -39
- package/package.json +1 -1
- package/src/cli/commands/context-command.ts +11 -9
- package/src/cli/commands/doctor-command.ts +5 -0
- package/src/cli/commands/findings-command.ts +13 -3
- package/src/cli/commands/review-command.ts +1 -2
- package/src/cli/commands/status-command.ts +46 -0
- package/src/cli/help.ts +8 -3
- package/src/cli/index.ts +5 -2
- package/src/core/commands/context.ts +29 -13
- package/src/core/commands/findings.ts +216 -125
- package/src/core/commands/review.ts +7 -8
- package/src/core/commands/scaffold.ts +15 -16
- package/src/core/commands/status.ts +63 -0
- package/src/core/config.ts +16 -7
- package/src/core/engine/houndsman-engine.ts +15 -1
- package/src/core/engine/review-scope.ts +26 -8
- package/src/core/engine/review-session.ts +37 -9
- package/src/core/findings-state.ts +53 -75
- package/src/core/github-review.ts +620 -57
- package/src/core/jev/jev-candidates.ts +49 -35
- package/src/core/orchestrator.ts +271 -134
- package/src/core/paths.ts +26 -17
- package/src/core/pr-context.ts +19 -5
- package/src/core/project-config.ts +4 -5
- package/src/core/renderers/manifest-writer.ts +29 -18
- package/src/core/renderers/resume-renderer.ts +151 -0
- package/src/core/report-builder.ts +7 -4
- package/src/core/run-layout.ts +4 -104
- package/src/core/run-status.ts +59 -50
- package/src/core/services/git-repository.ts +16 -0
- package/src/core/services/project-files.ts +13 -1
- package/src/core/storage/factory.ts +24 -0
- package/src/core/storage/file/file-storage.ts +271 -0
- package/src/core/storage/paths.ts +64 -0
- package/src/core/storage/repo-identity.ts +70 -0
- package/src/core/storage/sqlite/mappers.ts +162 -0
- package/src/core/storage/sqlite/schema.ts +116 -0
- package/src/core/storage/sqlite/sqlite-storage.ts +431 -0
- package/src/core/storage/types.ts +152 -0
- package/src/core/storage/work-area.ts +191 -0
- package/src/index.ts +27 -6
- package/src/server/artifacts.ts +8 -61
- package/src/server/client/App.tsx +26 -9
- package/src/server/client/ArtifactsPanel.tsx +144 -23
- package/src/server/client/AutoRefresh.tsx +1 -1
- package/src/server/client/Filters.tsx +7 -2
- package/src/server/client/FindingCard.tsx +50 -15
- package/src/server/client/FindingsList.tsx +2 -2
- package/src/server/client/MarkdownText.tsx +181 -0
- package/src/server/client/ResumePanel.tsx +368 -0
- package/src/server/client/RunList.tsx +3 -3
- package/src/server/client/SubmitModal.tsx +58 -10
- package/src/server/client/SummaryBar.tsx +1 -1
- package/src/server/client/icons.tsx +17 -0
- package/src/server/client/utils/code-highlight.ts +422 -0
- package/src/server/client/utils/constants.ts +226 -0
- package/src/server/client/utils/json-highlight.ts +55 -0
- package/src/server/client/utils/markdown.ts +319 -0
- package/src/server/client/{types.ts → utils/types.ts} +81 -0
- package/src/server/index.ts +154 -16
- package/src/server/run-repository.ts +153 -136
- package/src/skill/OUTPUT-FORMAT.md +6 -4
- package/src/skill/README.md +22 -14
- package/src/skill/SETUP.md +4 -4
- package/src/skill/example-review.md +22 -14
- package/src/skill/fix.md +5 -5
- package/src/skill/hunt.md +55 -32
- package/src/skill/train.md +1 -1
- package/src/skill/verify.md +7 -6
- package/src/tests/__snapshots__/baseline.test.ts.snap +74 -61
- package/src/tests/baseline.test.ts +79 -34
- package/src/tests/cli.test.ts +40 -19
- package/src/tests/code-highlight.test.ts +123 -0
- package/src/tests/domain.test.ts +1 -1
- package/src/tests/findings-commands.test.ts +153 -131
- package/src/tests/findings-state.test.ts +56 -5
- package/src/tests/fixtures/fake-project.ts +0 -1
- package/src/tests/github-review.test.ts +409 -60
- package/src/tests/infrastructure.test.ts +33 -38
- package/src/tests/markdown.test.ts +149 -0
- package/src/tests/review-resume.test.ts +101 -0
- package/src/tests/review-scope.test.ts +302 -0
- package/src/tests/review-scripts.test.ts +81 -5
- package/src/tests/run-status-view.test.ts +94 -0
- package/src/tests/run-status.test.ts +86 -23
- package/src/tests/server.test.ts +184 -96
- package/src/tests/storage.test.ts +449 -0
- package/src/server/client/constants.ts +0 -91
- /package/src/server/client/{api.ts → utils/api.ts} +0 -0
- /package/src/server/client/{format.ts → utils/format.ts} +0 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown mínimo do cliente (campo `action` do finding e artefatos).
|
|
3
|
+
*
|
|
4
|
+
* Suporta o suficiente para instruções curtas e relatórios: parágrafos,
|
|
5
|
+
* quebras de linha, listas, blockquote, títulos, tabelas, blocos de código e
|
|
6
|
+
* formatação inline (`code`, **bold**, *itálico*, links). Sem lib externa e
|
|
7
|
+
* sem HTML cru.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Alinhamento de coluna em tabela markdown. */
|
|
11
|
+
export type MarkdownAlign = "left" | "center" | "right";
|
|
12
|
+
|
|
13
|
+
export type MarkdownBlock =
|
|
14
|
+
| { type: "paragraph"; text: string }
|
|
15
|
+
| { type: "list"; ordered: boolean; items: string[] }
|
|
16
|
+
| { type: "code"; language: string | null; code: string }
|
|
17
|
+
| { type: "quote"; text: string }
|
|
18
|
+
| { type: "heading"; level: number; text: string }
|
|
19
|
+
| {
|
|
20
|
+
type: "table";
|
|
21
|
+
align: (MarkdownAlign | undefined)[];
|
|
22
|
+
header: string[];
|
|
23
|
+
rows: string[][];
|
|
24
|
+
}
|
|
25
|
+
| { type: "hr" };
|
|
26
|
+
|
|
27
|
+
export type InlineToken =
|
|
28
|
+
| { type: "text"; text: string }
|
|
29
|
+
| { type: "code"; text: string }
|
|
30
|
+
| { type: "strong"; text: string }
|
|
31
|
+
| { type: "em"; text: string }
|
|
32
|
+
| { type: "link"; text: string; url: string }
|
|
33
|
+
| { type: "path"; text: string };
|
|
34
|
+
|
|
35
|
+
const FENCE = /^\s*```(\S*)\s*$/;
|
|
36
|
+
const BULLET = /^\s*[-*+]\s+(.*)$/;
|
|
37
|
+
const ORDERED = /^\s*\d+\.\s+(.*)$/;
|
|
38
|
+
const QUOTE = /^\s*>\s?(.*)$/;
|
|
39
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
40
|
+
const HR = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
41
|
+
const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$/;
|
|
42
|
+
|
|
43
|
+
function isBlockStart(line: string): boolean {
|
|
44
|
+
return (
|
|
45
|
+
FENCE.test(line) ||
|
|
46
|
+
HEADING.test(line) ||
|
|
47
|
+
HR.test(line) ||
|
|
48
|
+
BULLET.test(line) ||
|
|
49
|
+
ORDERED.test(line) ||
|
|
50
|
+
QUOTE.test(line)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Quebra uma linha de tabela em células (remove os pipes das pontas). */
|
|
55
|
+
function splitTableRow(line: string): string[] {
|
|
56
|
+
return line
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/^\|/, "")
|
|
59
|
+
.replace(/\|$/, "")
|
|
60
|
+
.split("|")
|
|
61
|
+
.map((cell) => cell.trim());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `true` quando há um cabeçalho seguido da linha separadora de tabela. */
|
|
65
|
+
function isTableStart(lines: string[], index: number): boolean {
|
|
66
|
+
if (index + 1 >= lines.length) return false;
|
|
67
|
+
if (!lines[index].includes("|")) return false;
|
|
68
|
+
return TABLE_SEP.test(lines[index + 1]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Alinhamentos declarados na linha separadora (`:---`, `:---:`, `---:`). */
|
|
72
|
+
function parseAlignments(line: string): (MarkdownAlign | undefined)[] {
|
|
73
|
+
return splitTableRow(line).map((cell) => {
|
|
74
|
+
const left = cell.startsWith(":");
|
|
75
|
+
const right = cell.endsWith(":");
|
|
76
|
+
if (left && right) return "center";
|
|
77
|
+
if (right) return "right";
|
|
78
|
+
if (left) return "left";
|
|
79
|
+
return undefined;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Célula numérica ou razão (`12`, `3/4`) — colunas assim vão para a direita. */
|
|
84
|
+
const NUMERIC_CELL = /^[\d\s/]+$/;
|
|
85
|
+
|
|
86
|
+
/** Resolve o alinhamento: explícito > coluna só de números/barra > padrão. */
|
|
87
|
+
function resolveAlignments(
|
|
88
|
+
align: (MarkdownAlign | undefined)[],
|
|
89
|
+
rows: string[][],
|
|
90
|
+
): (MarkdownAlign | undefined)[] {
|
|
91
|
+
return align.map((value, column) => {
|
|
92
|
+
if (value) return value;
|
|
93
|
+
let filled = 0;
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
const cell = (row[column] ?? "").trim();
|
|
96
|
+
if (cell.length === 0) continue;
|
|
97
|
+
if (!NUMERIC_CELL.test(cell) || !/\d/.test(cell)) return undefined;
|
|
98
|
+
filled++;
|
|
99
|
+
}
|
|
100
|
+
return filled > 0 ? "right" : undefined;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Divide o texto em blocos simples (sem aninhamento profundo). */
|
|
105
|
+
export function parseMarkdown(text: string): MarkdownBlock[] {
|
|
106
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
107
|
+
const blocks: MarkdownBlock[] = [];
|
|
108
|
+
let index = 0;
|
|
109
|
+
|
|
110
|
+
while (index < lines.length) {
|
|
111
|
+
const line = lines[index];
|
|
112
|
+
if (line.trim().length === 0) {
|
|
113
|
+
index++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const fence = FENCE.exec(line);
|
|
118
|
+
if (fence) {
|
|
119
|
+
const language = fence[1] || null;
|
|
120
|
+
const code: string[] = [];
|
|
121
|
+
index++;
|
|
122
|
+
while (index < lines.length && !FENCE.test(lines[index])) {
|
|
123
|
+
code.push(lines[index]);
|
|
124
|
+
index++;
|
|
125
|
+
}
|
|
126
|
+
if (index < lines.length) index++;
|
|
127
|
+
blocks.push({ type: "code", language, code: code.join("\n") });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const heading = HEADING.exec(line);
|
|
132
|
+
if (heading) {
|
|
133
|
+
blocks.push({
|
|
134
|
+
type: "heading",
|
|
135
|
+
level: heading[1].length,
|
|
136
|
+
text: heading[2].trim(),
|
|
137
|
+
});
|
|
138
|
+
index++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (HR.test(line)) {
|
|
143
|
+
blocks.push({ type: "hr" });
|
|
144
|
+
index++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isTableStart(lines, index)) {
|
|
149
|
+
const header = splitTableRow(line);
|
|
150
|
+
const align = parseAlignments(lines[index + 1]);
|
|
151
|
+
const rows: string[][] = [];
|
|
152
|
+
index += 2;
|
|
153
|
+
while (
|
|
154
|
+
index < lines.length &&
|
|
155
|
+
lines[index].trim().length > 0 &&
|
|
156
|
+
lines[index].includes("|")
|
|
157
|
+
) {
|
|
158
|
+
rows.push(splitTableRow(lines[index]));
|
|
159
|
+
index++;
|
|
160
|
+
}
|
|
161
|
+
blocks.push({
|
|
162
|
+
type: "table",
|
|
163
|
+
align: resolveAlignments(align, rows),
|
|
164
|
+
header,
|
|
165
|
+
rows,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (BULLET.test(line)) {
|
|
171
|
+
const items: string[] = [];
|
|
172
|
+
while (index < lines.length) {
|
|
173
|
+
const item = BULLET.exec(lines[index]);
|
|
174
|
+
if (!item) break;
|
|
175
|
+
items.push(item[1]);
|
|
176
|
+
index++;
|
|
177
|
+
}
|
|
178
|
+
blocks.push({ type: "list", ordered: false, items });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (ORDERED.test(line)) {
|
|
183
|
+
const items: string[] = [];
|
|
184
|
+
while (index < lines.length) {
|
|
185
|
+
const item = ORDERED.exec(lines[index]);
|
|
186
|
+
if (!item) break;
|
|
187
|
+
items.push(item[1]);
|
|
188
|
+
index++;
|
|
189
|
+
}
|
|
190
|
+
blocks.push({ type: "list", ordered: true, items });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (QUOTE.test(line)) {
|
|
195
|
+
const quote: string[] = [];
|
|
196
|
+
while (index < lines.length) {
|
|
197
|
+
const item = QUOTE.exec(lines[index]);
|
|
198
|
+
if (!item) break;
|
|
199
|
+
quote.push(item[1]);
|
|
200
|
+
index++;
|
|
201
|
+
}
|
|
202
|
+
blocks.push({ type: "quote", text: quote.join("\n") });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const paragraph: string[] = [line];
|
|
207
|
+
index++;
|
|
208
|
+
while (
|
|
209
|
+
index < lines.length &&
|
|
210
|
+
lines[index].trim().length > 0 &&
|
|
211
|
+
!isBlockStart(lines[index]) &&
|
|
212
|
+
!isTableStart(lines, index)
|
|
213
|
+
) {
|
|
214
|
+
paragraph.push(lines[index]);
|
|
215
|
+
index++;
|
|
216
|
+
}
|
|
217
|
+
blocks.push({ type: "paragraph", text: paragraph.join("\n") });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return blocks;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Blocos de uma seção `## <título>` (sem o heading); vazio se ausente. */
|
|
224
|
+
export function sectionBlocks(text: string, title: string): MarkdownBlock[] {
|
|
225
|
+
const wanted = title.trim().toLowerCase();
|
|
226
|
+
const blocks: MarkdownBlock[] = [];
|
|
227
|
+
let inside = false;
|
|
228
|
+
for (const block of parseMarkdown(text)) {
|
|
229
|
+
if (block.type === "heading") {
|
|
230
|
+
if (inside) break;
|
|
231
|
+
inside = block.level >= 2 && block.text.trim().toLowerCase() === wanted;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (inside) blocks.push(block);
|
|
235
|
+
}
|
|
236
|
+
return blocks;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Extensão reconhecida como arquivo (evita destacar texto comum). */
|
|
240
|
+
const FILE_EXTENSION =
|
|
241
|
+
/\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|css|scss|html?|py|go|rs|java|kt|cs|php|rb|sh|sql|txt|lock)$/i;
|
|
242
|
+
|
|
243
|
+
/** Caminho/arquivo dentro de um texto corrido (`src/foo.ts`, `review.md`). */
|
|
244
|
+
const PATH_TOKEN =
|
|
245
|
+
/(?:[\w.-]+[\\/])*[\w-]+\.(?:[cm]?[jt]sx?|json|md|ya?ml|toml|css|scss|html?|py|go|rs|java|kt|cs|php|rb|sh|sql|txt|lock)\b/g;
|
|
246
|
+
|
|
247
|
+
/** `true` se o texto parece um caminho/arquivo (sem espaços e com extensão). */
|
|
248
|
+
export function isFilePath(value: string): boolean {
|
|
249
|
+
const text = value.trim();
|
|
250
|
+
if (!text || /\s/.test(text)) return false;
|
|
251
|
+
if (/^(?:https?:|mailto:|#)/i.test(text)) return false;
|
|
252
|
+
const base = text.split(/[\\/]/).pop() ?? text;
|
|
253
|
+
if (FILE_EXTENSION.test(base)) return true;
|
|
254
|
+
return (
|
|
255
|
+
text.startsWith("./") ||
|
|
256
|
+
text.startsWith("../") ||
|
|
257
|
+
text.startsWith("/") ||
|
|
258
|
+
/^[A-Za-z]:[\\/]/.test(text)
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Quebra texto corrido em tokens, destacando caminhos de arquivo. */
|
|
263
|
+
function pushTextTokens(tokens: InlineToken[], text: string): void {
|
|
264
|
+
let last = 0;
|
|
265
|
+
for (const match of text.matchAll(PATH_TOKEN)) {
|
|
266
|
+
const index = match.index ?? 0;
|
|
267
|
+
if (index > last) {
|
|
268
|
+
tokens.push({ type: "text", text: text.slice(last, index) });
|
|
269
|
+
}
|
|
270
|
+
tokens.push({ type: "path", text: match[0] });
|
|
271
|
+
last = index + match[0].length;
|
|
272
|
+
}
|
|
273
|
+
if (last < text.length) {
|
|
274
|
+
tokens.push({ type: "text", text: text.slice(last) });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Tokeniza formatação inline preservando o texto e a ordem dos marcadores. */
|
|
279
|
+
export function parseInline(text: string): InlineToken[] {
|
|
280
|
+
const tokens: InlineToken[] = [];
|
|
281
|
+
// Ordem importa: code/bold antes de itálico; `_` exige fronteira de palavra.
|
|
282
|
+
const pattern =
|
|
283
|
+
/`[^`\n]+`|\*\*[^*\n]+\*\*|__[^_\n]+__|\*[^*\n]+\*|(?<![\w])_[^_\n]+_(?![\w])|\[[^\]\n]+\]\([^)\s]+\)/g;
|
|
284
|
+
let last = 0;
|
|
285
|
+
for (const match of text.matchAll(pattern)) {
|
|
286
|
+
const start = match.index ?? 0;
|
|
287
|
+
if (start > last) {
|
|
288
|
+
pushTextTokens(tokens, text.slice(last, start));
|
|
289
|
+
}
|
|
290
|
+
const raw = match[0];
|
|
291
|
+
if (raw.startsWith("`")) {
|
|
292
|
+
tokens.push({ type: "code", text: raw.slice(1, -1) });
|
|
293
|
+
} else if (raw.startsWith("**") || raw.startsWith("__")) {
|
|
294
|
+
tokens.push({ type: "strong", text: raw.slice(2, -2) });
|
|
295
|
+
} else if (raw.startsWith("*") || raw.startsWith("_")) {
|
|
296
|
+
tokens.push({ type: "em", text: raw.slice(1, -1) });
|
|
297
|
+
} else {
|
|
298
|
+
const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(raw);
|
|
299
|
+
if (link) {
|
|
300
|
+
tokens.push({ type: "link", text: link[1], url: link[2] });
|
|
301
|
+
} else {
|
|
302
|
+
pushTextTokens(tokens, raw);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
last = start + raw.length;
|
|
306
|
+
}
|
|
307
|
+
if (last < text.length) {
|
|
308
|
+
pushTextTokens(tokens, text.slice(last));
|
|
309
|
+
}
|
|
310
|
+
return tokens;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** URL segura para links de markdown (evita `javascript:` e afins). */
|
|
314
|
+
export function safeUrl(url: string): string | null {
|
|
315
|
+
const value = url.trim();
|
|
316
|
+
if (/^(https?:|mailto:)/i.test(value)) return value;
|
|
317
|
+
if (value.startsWith("/") && !value.startsWith("//")) return value;
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
@@ -50,10 +50,37 @@ export type Summary = {
|
|
|
50
50
|
bySeverity: Record<Severity, number>;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/** Espécie de artefato persistido (espelha `core/storage/types.ts`). */
|
|
54
|
+
export type ArtifactKind =
|
|
55
|
+
| "brief"
|
|
56
|
+
| "classification"
|
|
57
|
+
| "manifest"
|
|
58
|
+
| "candidates"
|
|
59
|
+
| "context"
|
|
60
|
+
| "context-brief"
|
|
61
|
+
| "context-summary"
|
|
62
|
+
| "finding-report"
|
|
63
|
+
| "review"
|
|
64
|
+
| "resume"
|
|
65
|
+
| "domain-review"
|
|
66
|
+
| "verify"
|
|
67
|
+
| "other";
|
|
68
|
+
|
|
69
|
+
/** Metadados de artefato retornados pela API (sem conteúdo). */
|
|
70
|
+
export type ArtifactMeta = {
|
|
71
|
+
name: string;
|
|
72
|
+
kind: ArtifactKind;
|
|
73
|
+
contentType: string;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
53
77
|
export type RunSummary = {
|
|
54
78
|
slug: string;
|
|
55
79
|
branch: string;
|
|
56
80
|
updatedAt?: string;
|
|
81
|
+
startedAt?: string;
|
|
82
|
+
/** Último sinal de vida de uma execução ativa (revisão/correção). */
|
|
83
|
+
heartbeatAt?: string;
|
|
57
84
|
status?: RunStatus | null;
|
|
58
85
|
summary: Summary;
|
|
59
86
|
};
|
|
@@ -64,6 +91,52 @@ export type FindingsResponse = {
|
|
|
64
91
|
summary: Summary;
|
|
65
92
|
};
|
|
66
93
|
|
|
94
|
+
/** Manifesto da run (subset consumido pela aba de resumo). */
|
|
95
|
+
export type RunManifest = {
|
|
96
|
+
branch?: string;
|
|
97
|
+
base?: string;
|
|
98
|
+
head?: string;
|
|
99
|
+
generatedAt?: string;
|
|
100
|
+
churn?: { additions: number; deletions: number };
|
|
101
|
+
watchdogs?: {
|
|
102
|
+
id: string;
|
|
103
|
+
name: string;
|
|
104
|
+
files: string[];
|
|
105
|
+
overflow: number;
|
|
106
|
+
status: "pending" | "done";
|
|
107
|
+
}[];
|
|
108
|
+
context?: {
|
|
109
|
+
summarized: boolean;
|
|
110
|
+
source?: {
|
|
111
|
+
number?: number;
|
|
112
|
+
title?: string;
|
|
113
|
+
url?: string;
|
|
114
|
+
base?: string;
|
|
115
|
+
head?: string;
|
|
116
|
+
} | null;
|
|
117
|
+
};
|
|
118
|
+
excludedCount?: number;
|
|
119
|
+
deleted?: string[];
|
|
120
|
+
unclassified?: string[];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Detalhe da run (`GET /api/runs/:slug`). */
|
|
124
|
+
export type RunDetail = {
|
|
125
|
+
slug: string;
|
|
126
|
+
branch: string;
|
|
127
|
+
manifest: RunManifest | null;
|
|
128
|
+
watchdogs: string[];
|
|
129
|
+
unclassified: string[];
|
|
130
|
+
deleted: string[];
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** `classification.json` — só o necessário para os cards do resumo. */
|
|
134
|
+
export type ClassificationArtifact = {
|
|
135
|
+
files?: Record<string, { status: string; oldPath?: string }>;
|
|
136
|
+
churn?: Record<string, { additions: number; deletions: number }>;
|
|
137
|
+
watchdogs?: Record<string, string[]>;
|
|
138
|
+
};
|
|
139
|
+
|
|
67
140
|
/** Candidato ao envio da revisão (espelha `core/github-review.ts`). */
|
|
68
141
|
export type SubmitCandidate = {
|
|
69
142
|
id: string;
|
|
@@ -111,6 +184,14 @@ export type SubmitOutcome = {
|
|
|
111
184
|
failed: { id: string; reason: string }[];
|
|
112
185
|
skipped: { id: string; reason: string }[];
|
|
113
186
|
general?: { commentId: number; url?: string; ids: string[] };
|
|
187
|
+
review?: {
|
|
188
|
+
id?: number;
|
|
189
|
+
url?: string;
|
|
190
|
+
created: boolean;
|
|
191
|
+
finalized: boolean;
|
|
192
|
+
event?: "COMMENT" | "REQUEST_CHANGES";
|
|
193
|
+
error?: string;
|
|
194
|
+
};
|
|
114
195
|
};
|
|
115
196
|
|
|
116
197
|
export type SubmitResponse = {
|
package/src/server/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Servidor local do houndsman (`Bun.serve`) — UI de triagem + artefatos.
|
|
3
3
|
*
|
|
4
|
-
* Serve um SPA Preact (bundle em memória) + uma API JSON sobre o
|
|
5
|
-
*
|
|
6
|
-
* Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
|
|
4
|
+
* Serve um SPA Preact (bundle em memória) + uma API JSON sobre o storage global
|
|
5
|
+
* (SQLite por default), escopada ao repositório do cwd. Sem dependências além do
|
|
6
|
+
* Bun e do Preact. Bind padrão em `127.0.0.1` — ferramenta local, sem autenticação.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { mediaPath } from "../core/assets";
|
|
@@ -14,6 +14,9 @@ import {
|
|
|
14
14
|
type GhRunner,
|
|
15
15
|
type SubmitSelection,
|
|
16
16
|
} from "../core/github-review";
|
|
17
|
+
import { createStorage } from "../core/storage/factory";
|
|
18
|
+
import { resolveRepoIdentity } from "../core/storage/repo-identity";
|
|
19
|
+
import type { RepoIdentity, StorageProvider } from "../core/storage/types";
|
|
17
20
|
import { buildClientBundle } from "./client-build";
|
|
18
21
|
import { RunRepository } from "./run-repository";
|
|
19
22
|
|
|
@@ -53,7 +56,11 @@ function notFound(detail = "não encontrado"): Response {
|
|
|
53
56
|
// ---------------------------------------------------------------------------
|
|
54
57
|
|
|
55
58
|
/** Repositório das runs (dados) — ver `run-repository.ts`. */
|
|
56
|
-
type ServerDeps = {
|
|
59
|
+
type ServerDeps = {
|
|
60
|
+
ghRunner?: GhRunner;
|
|
61
|
+
storage?: StorageProvider;
|
|
62
|
+
repo?: RepoIdentity;
|
|
63
|
+
};
|
|
57
64
|
|
|
58
65
|
// ---------------------------------------------------------------------------
|
|
59
66
|
// Rotas
|
|
@@ -142,6 +149,9 @@ async function route(
|
|
|
142
149
|
if (!artifact) return notFound(`artefato '${name}' não encontrado`);
|
|
143
150
|
return text(artifact.content, artifact.type);
|
|
144
151
|
}
|
|
152
|
+
if (rest === "/artifacts" && request.method === "GET") {
|
|
153
|
+
return json({ artifacts: runs.artifacts(slug) ?? [] });
|
|
154
|
+
}
|
|
145
155
|
|
|
146
156
|
return notFound();
|
|
147
157
|
}
|
|
@@ -152,7 +162,10 @@ export class HoundsmanServer {
|
|
|
152
162
|
private readonly ghRunner: GhRunner;
|
|
153
163
|
|
|
154
164
|
constructor(config: HoundsmanConfig, deps: ServerDeps = {}) {
|
|
155
|
-
this.runs = RunRepository
|
|
165
|
+
this.runs = new RunRepository(
|
|
166
|
+
deps.storage ?? createStorage(config),
|
|
167
|
+
deps.repo ?? resolveRepoIdentity(),
|
|
168
|
+
);
|
|
156
169
|
this.ghRunner = deps.ghRunner ?? defaultGhRunner;
|
|
157
170
|
}
|
|
158
171
|
|
|
@@ -173,11 +186,26 @@ export class HoundsmanServer {
|
|
|
173
186
|
server: BunServer;
|
|
174
187
|
url: string;
|
|
175
188
|
} {
|
|
189
|
+
// Manutenção do banco no start: compacta o que sobrou de runs antigas.
|
|
190
|
+
try {
|
|
191
|
+
this.runs.maintain();
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`[houndsman] VACUUM falhou (seguindo sem compactar): ${
|
|
195
|
+
error instanceof Error ? error.message : String(error)
|
|
196
|
+
}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
176
199
|
const host = options.host ?? "127.0.0.1";
|
|
177
200
|
const port = options.port ?? 7331;
|
|
178
201
|
const server = Bun.serve({ hostname: host, port, fetch: this.handler });
|
|
179
202
|
return { server, url: `http://${host}:${server.port}` };
|
|
180
203
|
}
|
|
204
|
+
|
|
205
|
+
/** Fecha o provider (após `server.stop()`). */
|
|
206
|
+
dispose(): void {
|
|
207
|
+
this.runs.dispose();
|
|
208
|
+
}
|
|
181
209
|
}
|
|
182
210
|
|
|
183
211
|
/** Cria o handler de requisições (testável sem abrir porta). */
|
|
@@ -194,11 +222,18 @@ export type ServerOptions = {
|
|
|
194
222
|
host?: string;
|
|
195
223
|
/** Runner do `gh` (injetável nos testes). */
|
|
196
224
|
ghRunner?: GhRunner;
|
|
225
|
+
/** Provider/storage injetável (testes). */
|
|
226
|
+
storage?: StorageProvider;
|
|
227
|
+
repo?: RepoIdentity;
|
|
197
228
|
};
|
|
198
229
|
|
|
199
230
|
/** Sobe o servidor e devolve a instância + URL. */
|
|
200
231
|
export function startServer(options: ServerOptions): { server: BunServer; url: string } {
|
|
201
|
-
return new HoundsmanServer(options.config, {
|
|
232
|
+
return new HoundsmanServer(options.config, {
|
|
233
|
+
ghRunner: options.ghRunner,
|
|
234
|
+
storage: options.storage,
|
|
235
|
+
repo: options.repo,
|
|
236
|
+
}).start({
|
|
202
237
|
port: options.port,
|
|
203
238
|
host: options.host,
|
|
204
239
|
});
|
|
@@ -227,7 +262,7 @@ html, body { height:100%; }
|
|
|
227
262
|
body { margin:0; font-size:14px; background:var(--bg); color:var(--fg); font-family:var(--font-sans); -webkit-font-smoothing:antialiased; }
|
|
228
263
|
body::before { content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
|
|
229
264
|
background:radial-gradient(820px 420px at 8% -12%, rgba(240,165,44,.08), transparent 62%); }
|
|
230
|
-
code, pre, .mono { font-family:var(--font-mono); }
|
|
265
|
+
code, pre, .mono { font-family:var(--font-mono); tab-size:4; }
|
|
231
266
|
::-webkit-scrollbar { width:10px; height:10px; }
|
|
232
267
|
::-webkit-scrollbar-thumb { background:rgba(255,255,255,.1); border:2px solid transparent; border-radius:999px; background-clip:padding-box; }
|
|
233
268
|
::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.18); background-clip:padding-box; }
|
|
@@ -280,7 +315,7 @@ code, pre, .mono { font-family:var(--font-mono); }
|
|
|
280
315
|
.btn:disabled { opacity:.55; cursor:not-allowed; }
|
|
281
316
|
.icon.spin { animation:spin .8s linear infinite; }
|
|
282
317
|
.content { width:100%; max-width:1180px; margin:0 auto; padding:22px 24px 64px; }
|
|
283
|
-
.page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
|
|
318
|
+
.page-header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; margin-bottom:16px; }
|
|
284
319
|
.page-header-text { min-width:0; }
|
|
285
320
|
.page-header-actions { display:flex; align-items:center; gap:10px; flex:none; }
|
|
286
321
|
.btn.submit-review { color:#1b1305; background:linear-gradient(180deg, var(--accent-strong), var(--accent)); border-color:transparent; box-shadow:0 4px 14px rgba(240,165,44,.22); }
|
|
@@ -369,13 +404,48 @@ code, pre, .mono { font-family:var(--font-mono); }
|
|
|
369
404
|
.meta { display:inline-flex; align-items:center; gap:7px; }
|
|
370
405
|
.meta-k { font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
|
|
371
406
|
.meta code { padding:2px 6px; font-size:11.5px; color:var(--fg); background:rgba(255,255,255,.04); border:1px solid var(--border); border-radius:6px; }
|
|
372
|
-
.action-block { display:flex; gap:11px; padding:
|
|
373
|
-
.action-icon { flex:none; display:inline-block; margin-
|
|
374
|
-
.action-
|
|
375
|
-
.action-
|
|
407
|
+
.action-block { display:flex; gap:11px; padding:0; background:transparent; border:0; border-radius:0; }
|
|
408
|
+
.action-icon { flex:none; display:inline-block; margin-top:3px; margin-right:2px; color:var(--accent); }
|
|
409
|
+
.action-text { flex:1; min-width:0; }
|
|
410
|
+
.action-label { display:block; margin-bottom:4px; font-size:10px; font-weight:600; letter-spacing:.12em; text-transform:uppercase; color:var(--accent); }
|
|
411
|
+
.action-text p, .md-p { margin:0 0 6px; font-size:13px; line-height:1.65; color:#f0ece6; white-space:pre-wrap; }
|
|
412
|
+
.action-text p:last-child { margin-bottom:0; }
|
|
413
|
+
.md-list { margin:0 0 6px; padding-left:20px; font-size:13px; line-height:1.65; color:#f0ece6; }
|
|
414
|
+
.md-list li { margin:0 0 2px; }
|
|
415
|
+
.md-quote { margin:0 0 6px; padding:2px 0 2px 10px; border-left:2px solid var(--accent-line); color:var(--muted); font-size:13px; line-height:1.65; white-space:pre-wrap; }
|
|
416
|
+
.action-text code, .md-code { font-family:var(--font-mono); font-size:11.5px; padding:1px 5px; color:#f0ece6; background:rgba(255,255,255,.06); border:1px solid var(--border); border-radius:5px; }
|
|
417
|
+
code.md-path { color:#8fc7ff; background:rgba(90,164,240,.1); border-color:rgba(90,164,240,.24); }
|
|
418
|
+
.action-text a, .md-p a, .md-list a, .md-quote a, .md-table a, .md-h a { color:var(--accent-strong); text-decoration:underline; text-underline-offset:2px; }
|
|
419
|
+
.action-text strong, .md-p strong, .md-list strong, .md-quote strong, .md-table strong, .md-h strong { color:#fff; font-weight:600; }
|
|
420
|
+
.action-text em, .md-p em, .md-list em, .md-quote em, .md-table em, .md-h em { color:#e9e4dc; }
|
|
421
|
+
.md-code-block { margin:6px 0; padding:10px 12px; max-height:320px; overflow:auto; white-space:pre; font-size:11.5px; line-height:1.6; color:#cdc7bf; background:#0b0b0e; border-radius:10px; }
|
|
422
|
+
.md-h { margin:16px 0 8px; font-size:14px; font-weight:700; line-height:1.35; letter-spacing:-.01em; color:#eee9e2; }
|
|
423
|
+
.md-h:first-child { margin-top:0; }
|
|
424
|
+
.md-h1 { font-size:19px; }
|
|
425
|
+
.md-h2 { font-size:16px; }
|
|
426
|
+
.md-h3 { font-size:14px; }
|
|
427
|
+
.md-h4, .md-h5, .md-h6 { font-size:13px; color:#e4dfd7; font-weight:600; }
|
|
428
|
+
.md-hr { margin:14px 0; border:0; border-top:1px solid var(--border); }
|
|
429
|
+
.md-table-wrap { margin:8px 0 12px; padding:4px; overflow-x:auto; border:1px solid rgba(255,255,255,.07); border-radius:10px; background:rgba(255,255,255,.012); }
|
|
430
|
+
.md-table { width:100%; border-collapse:separate; border-spacing:3px; font-size:12px; line-height:1.5; }
|
|
431
|
+
.md-table th, .md-table td { padding:6px 9px; text-align:left; vertical-align:top; border:1px solid rgba(255,255,255,.035); border-radius:6px; }
|
|
432
|
+
.md-table th { color:var(--muted); font-weight:600; background:rgba(255,255,255,.03); }
|
|
433
|
+
.md-table td { color:#d8d3cc; }
|
|
376
434
|
.decision-tags-block { display:flex; align-items:center; gap:8px; }
|
|
377
435
|
.evidence { margin:0; padding: 16px; white-space:break-spaces; font-size:12px; line-height:1.6; color:#cdc7bf;
|
|
378
436
|
background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
|
|
437
|
+
.evidence-block { display:flex; flex-direction:column; gap:6px; }
|
|
438
|
+
.evidence-head { display:flex; justify-content:flex-end; }
|
|
439
|
+
.code-lang { font-family:var(--font-mono); font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
|
|
440
|
+
.code-evidence { white-space:pre; overflow:auto; }
|
|
441
|
+
.tok-keyword { color:#c792ea; }
|
|
442
|
+
.tok-string { color:#9ddc8a; }
|
|
443
|
+
.tok-comment { color:#6d6862; font-style:italic; }
|
|
444
|
+
.tok-number { color:#8fc7ff; }
|
|
445
|
+
.tok-type { color:var(--accent-strong); }
|
|
446
|
+
.tok-function { color:#82aaff; }
|
|
447
|
+
.tok-property { color:#e6c07b; }
|
|
448
|
+
.tok-punct { color:#8d8a85; }
|
|
379
449
|
.tags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin-left: auto; }
|
|
380
450
|
.tags-label { display:inline-flex; align-items:center; gap:6px; font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
|
|
381
451
|
.tag { display:inline-flex; align-items:center; gap:6px; padding:3px 9px; font-family:var(--font-mono); font-size:11px; color:var(--muted);
|
|
@@ -398,14 +468,82 @@ code, pre, .mono { font-family:var(--font-mono); }
|
|
|
398
468
|
font-family:var(--font-mono); font-size:11px; color:var(--muted); }
|
|
399
469
|
.comment-status.saved { color:var(--success); }
|
|
400
470
|
.artifacts { display:flex; flex-direction:column; }
|
|
401
|
-
.artifact-
|
|
402
|
-
.artifact-
|
|
403
|
-
.artifact-
|
|
404
|
-
.artifact-
|
|
471
|
+
.artifact-layout { display:grid; grid-template-columns:minmax(190px,250px) minmax(0,1fr); gap:16px; align-items:start; }
|
|
472
|
+
.artifact-list { display:flex; flex-direction:column; gap:14px; max-height:72vh; overflow:auto; padding:2px 4px 2px 0; }
|
|
473
|
+
.artifact-group { display:flex; flex-direction:column; gap:4px; }
|
|
474
|
+
.artifact-group-label { margin:0 0 2px; font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); }
|
|
475
|
+
.artifact-item { display:flex; flex-direction:column; gap:2px; text-align:left; cursor:pointer; font:inherit; color:var(--muted);
|
|
476
|
+
padding:8px 10px; background:transparent; border:1px solid transparent; border-radius:10px;
|
|
477
|
+
transition:background .15s ease, border-color .15s ease, color .15s ease; }
|
|
478
|
+
.artifact-item:hover { color:var(--fg); background:var(--surface-2); border-color:var(--border); }
|
|
479
|
+
.artifact-item.active { color:var(--fg); background:var(--accent-soft); border-color:var(--accent-line); }
|
|
480
|
+
.artifact-item-name { font-family:var(--font-mono); font-size:11.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
481
|
+
.artifact-item-kind { font-size:9.5px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-2); }
|
|
482
|
+
.artifact-item.active .artifact-item-kind { color:var(--accent); }
|
|
483
|
+
.artifact-pane { min-width:0; }
|
|
484
|
+
.artifact-head { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:12px; }
|
|
485
|
+
.artifact-title { margin:0 0 5px; font-family:var(--font-mono); font-size:13px; font-weight:600; word-break:break-word; }
|
|
486
|
+
.artifact-desc { margin:0; max-width:78ch; color:var(--muted); font-size:12.5px; line-height:1.55; }
|
|
487
|
+
.artifact-kind-badge { flex:none; display:inline-flex; align-items:center; padding:3px 9px; font-size:10px; font-weight:700;
|
|
488
|
+
letter-spacing:.08em; text-transform:uppercase; color:var(--accent); background:var(--accent-soft);
|
|
489
|
+
border:1px solid var(--accent-line); border-radius:999px; }
|
|
490
|
+
.artifact-view { margin:0; padding:14px 16px; max-height:70vh; overflow:auto; font-size:12px; line-height:1.6; color:#cdc7bf;
|
|
405
491
|
background:#0b0b0e; border:1px solid var(--border); border-radius:var(--radius); }
|
|
492
|
+
.artifact-markdown { white-space:normal; }
|
|
493
|
+
.artifact-markdown .md-p, .artifact-markdown .md-list, .artifact-markdown .md-quote { font-size:12.5px; }
|
|
494
|
+
.artifact-markdown .md-p, .artifact-markdown .md-list { color:#d8d3cc; }
|
|
495
|
+
.artifact-markdown .md-code-block { border:1px solid var(--border); }
|
|
496
|
+
.json-view { white-space:pre; }
|
|
497
|
+
.json-key { color:var(--accent-strong); }
|
|
498
|
+
.json-string { color:#9ddc8a; }
|
|
499
|
+
.json-number { color:#8fc7ff; }
|
|
500
|
+
.json-literal { color:#d7a6ff; }
|
|
501
|
+
.json-punct { color:#8d8a85; }
|
|
502
|
+
@media (max-width:900px) { .artifact-layout { grid-template-columns:1fr; } .artifact-list { max-height:240px; } }
|
|
406
503
|
.sent-tag { display:inline-flex; align-items:center; gap:5px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
|
|
407
504
|
color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.32); border-radius:999px; text-decoration:none; }
|
|
408
505
|
.sent-tag:hover { color:#fff; }
|
|
506
|
+
.sent-tag.gh { color:#b9c7d6; background:rgba(139,164,191,.12); border-color:rgba(139,164,191,.35); }
|
|
507
|
+
.sent-tag.gh:hover { color:#fff; background:rgba(139,164,191,.2); }
|
|
508
|
+
.resume { display:flex; flex-direction:column; }
|
|
509
|
+
.resume-hero { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:18px 20px;
|
|
510
|
+
background:linear-gradient(135deg, rgba(240,165,44,.08), rgba(255,255,255,.015) 55%);
|
|
511
|
+
border:1px solid var(--border); border-radius:var(--radius-lg); }
|
|
512
|
+
.resume-kicker { display:block; font-size:10px; font-weight:700; letter-spacing:.14em; text-transform:uppercase; color:var(--accent); }
|
|
513
|
+
.resume-title { margin:5px 0 7px; font-size:22px; font-weight:700; letter-spacing:-.02em; word-break:break-word; }
|
|
514
|
+
.resume-meta { display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin:0; font-size:12px; color:var(--muted); }
|
|
515
|
+
.resume-pr { display:inline-flex; align-items:center; gap:5px; color:var(--accent-strong); text-decoration:none; }
|
|
516
|
+
.resume-pr:hover { text-decoration:underline; }
|
|
517
|
+
.resume-hero-side { display:flex; flex:none; flex-direction:column; align-items:flex-end; gap:10px; }
|
|
518
|
+
.resume-status { display:inline-flex; align-items:center; padding:5px 12px; font-size:11px; font-weight:700; letter-spacing:.04em; border-radius:999px; }
|
|
519
|
+
.resume-status.blocking { color:var(--blocking); background:rgba(242,89,94,.12); border:1px solid rgba(242,89,94,.35); }
|
|
520
|
+
.resume-status.attention { color:var(--attention); background:rgba(237,161,58,.12); border:1px solid rgba(237,161,58,.35); }
|
|
521
|
+
.resume-status.clean { color:var(--success); background:rgba(69,192,122,.12); border:1px solid rgba(69,192,122,.35); }
|
|
522
|
+
.resume-kpis { margin:14px 0 18px; }
|
|
523
|
+
.resume-kpis .kpi-value { font-size:24px; }
|
|
524
|
+
.churn-add { color:var(--success); }
|
|
525
|
+
.churn-del { color:var(--blocking); }
|
|
526
|
+
.churn-sep { margin:0 3px; color:var(--muted-2); font-weight:400; }
|
|
527
|
+
.kpi-blocking .kpi-value { color:var(--blocking); }
|
|
528
|
+
.kpi-attention .kpi-value { color:var(--attention); }
|
|
529
|
+
.kpi-total .kpi-value { color:var(--fg); }
|
|
530
|
+
.resume-grid { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:16px; align-items:start; }
|
|
531
|
+
.resume-card { min-width:0; padding:16px 18px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius-lg); }
|
|
532
|
+
.resume-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
|
533
|
+
.resume-card-title { margin:0 0 10px; font-size:12px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); }
|
|
534
|
+
.resume-card-count { display:inline-flex; align-items:center; justify-content:center; min-width:22px; height:22px; margin-bottom:10px; padding:0 7px;
|
|
535
|
+
font-family:var(--font-mono); font-size:11px; color:var(--accent); background:var(--accent-soft); border:1px solid var(--accent-line); border-radius:999px; }
|
|
536
|
+
.resume-narrative .md-p:last-child { margin-bottom:0; }
|
|
537
|
+
.resume-domains { display:flex; flex-direction:column; gap:8px; margin:0; padding:0; list-style:none; }
|
|
538
|
+
.resume-domain { display:flex; flex-direction:column; gap:7px; padding:10px 12px; background:var(--surface-2);
|
|
539
|
+
border:1px solid var(--border); border-radius:10px; }
|
|
540
|
+
.resume-domain-head { display:flex; align-items:baseline; justify-content:space-between; gap:10px; }
|
|
541
|
+
.resume-domain-name { font-size:12.5px; font-weight:600; color:var(--fg); }
|
|
542
|
+
.resume-domain-files { flex:none; font-family:var(--font-mono); font-size:10.5px; color:var(--muted-2); }
|
|
543
|
+
.resume-domain-sev { display:flex; flex-wrap:wrap; gap:6px; }
|
|
544
|
+
.resume-domain-sev .sev-badge { padding:2px 7px; font-size:9.5px; }
|
|
545
|
+
.resume-domain-ok { font-size:11.5px; color:var(--success); }
|
|
546
|
+
@media (max-width:900px) { .resume-grid { grid-template-columns:1fr; } .resume-hero { flex-direction:column; } .resume-hero-side { flex-direction:row; align-items:flex-start; } }
|
|
409
547
|
.modal-layer { position:fixed; inset:0; z-index:50; display:grid; place-items:center; padding:24px; }
|
|
410
548
|
.modal-backdrop { position:absolute; inset:0; width:100%; padding:0; background:rgba(5,5,7,.66); border:0; cursor:default; backdrop-filter:blur(5px); -webkit-backdrop-filter:blur(5px); }
|
|
411
549
|
.modal { position:relative; width:min(640px, 100%); max-height:min(78vh, 780px); display:flex; flex-direction:column; overflow:hidden;
|