trackfw 6.0.0 → 6.1.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/package.json +1 -1
- package/src/generators/roadmap.js +14 -2
- package/src/serve/static/app.js +295 -18
- package/src/serve/static/index.html +106 -6
- package/src/serve/static/style.css +79 -0
- package/src/validator/index.js +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, and Amazon Q.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -419,10 +419,12 @@ function newRoadmap(title, reqPath) {
|
|
|
419
419
|
const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
|
|
420
420
|
fs.mkdirSync(backlogDir, { recursive: true })
|
|
421
421
|
|
|
422
|
+
const reqField = reqPath ? `"${reqPath}"` : '""'
|
|
423
|
+
|
|
422
424
|
const body = `---
|
|
423
425
|
status: backlog
|
|
424
426
|
date: ${date}
|
|
425
|
-
req:
|
|
427
|
+
req: ${reqField}
|
|
426
428
|
squad: ""
|
|
427
429
|
---
|
|
428
430
|
|
|
@@ -434,6 +436,11 @@ squad: ""
|
|
|
434
436
|
<!-- What problem does this roadmap solve? Link the REQ. -->
|
|
435
437
|
REQ: ${reqPath || ''}
|
|
436
438
|
|
|
439
|
+
## Acceptance Criteria
|
|
440
|
+
<!-- Consolidated criteria for this roadmap. Detail per ML in the waves below. -->
|
|
441
|
+
- [ ]
|
|
442
|
+
- [ ]
|
|
443
|
+
|
|
437
444
|
## Wave 1 — <name> (parallel MLs)
|
|
438
445
|
> Dependencies: none
|
|
439
446
|
|
|
@@ -509,7 +516,7 @@ function newRoadmapFromReq(reqPath) {
|
|
|
509
516
|
const body = `---
|
|
510
517
|
status: backlog
|
|
511
518
|
date: ${date}
|
|
512
|
-
req: "${
|
|
519
|
+
req: "${reqPath}"
|
|
513
520
|
squad: ""
|
|
514
521
|
---
|
|
515
522
|
|
|
@@ -521,6 +528,11 @@ squad: ""
|
|
|
521
528
|
<!-- Derived from REQ: ${basename} -->
|
|
522
529
|
REQ: ${reqPath}${adrRef}
|
|
523
530
|
|
|
531
|
+
## Acceptance Criteria
|
|
532
|
+
<!-- Consolidated criteria for this roadmap. Detail per ML in the waves below. -->
|
|
533
|
+
- [ ]
|
|
534
|
+
- [ ]
|
|
535
|
+
|
|
524
536
|
${mlSection}
|
|
525
537
|
`
|
|
526
538
|
|
package/src/serve/static/app.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* trackfw dashboard — app.js
|
|
2
|
-
Tecnologias:
|
|
2
|
+
Tecnologias: Tailwind CSS + marked.js + DOMPurify + Chart.js + D3.js (todos via CDN)
|
|
3
3
|
Sem bundler, sem npm no runtime.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -14,6 +14,12 @@ let _burnChart = null; // instância Chart.js burndown
|
|
|
14
14
|
let _d3Sim = null; // simulação D3 force
|
|
15
15
|
let _drawerPath = null; // path atualmente aberto no drawer
|
|
16
16
|
|
|
17
|
+
// ─── Views de lista (ADRs / REQs) ──────────────────────────────────────────────
|
|
18
|
+
let _listState = {
|
|
19
|
+
adr: { search: '', status: '' },
|
|
20
|
+
req: { search: '', status: '' },
|
|
21
|
+
};
|
|
22
|
+
|
|
17
23
|
// ─── Auto-refresh ─────────────────────────────────────────────────────────────
|
|
18
24
|
let _refreshInterval = parseInt(localStorage.getItem('trackfw_refresh_interval') || '60');
|
|
19
25
|
let _refreshPaused = localStorage.getItem('trackfw_refresh_paused') === 'true';
|
|
@@ -98,6 +104,8 @@ function switchView(view) {
|
|
|
98
104
|
loadChain();
|
|
99
105
|
} else if (view === 'metrics') {
|
|
100
106
|
loadMetrics();
|
|
107
|
+
} else if (view === 'adr' || view === 'req') {
|
|
108
|
+
loadListView(view);
|
|
101
109
|
}
|
|
102
110
|
}
|
|
103
111
|
|
|
@@ -572,6 +580,169 @@ function renderChain(data) {
|
|
|
572
580
|
});
|
|
573
581
|
}
|
|
574
582
|
|
|
583
|
+
// ─── View: ADRs / REQs (listas navegáveis) ────────────────────────────────────
|
|
584
|
+
|
|
585
|
+
function normalizeText(str) {
|
|
586
|
+
return (str || '')
|
|
587
|
+
.toString()
|
|
588
|
+
.normalize('NFD')
|
|
589
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
590
|
+
.toLowerCase();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function loadListView(type) {
|
|
594
|
+
hide(`${type}-error`);
|
|
595
|
+
|
|
596
|
+
// Reusa o cache de /api/chain já usado pela aba Chain. Se o cache já foi
|
|
597
|
+
// aquecido (ex.: usuário visitou a aba Chain antes), o spinner nunca chega
|
|
598
|
+
// a ser exibido por show('${type}-loading') — mas ele parte visível no
|
|
599
|
+
// HTML (sem classe "hidden"), então precisa ser escondido explicitamente
|
|
600
|
+
// aqui também, não só no ramo de erro/loading do fetch abaixo.
|
|
601
|
+
hide(`${type}-loading`);
|
|
602
|
+
if (_chainData) {
|
|
603
|
+
renderListView(type);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
show(`${type}-loading`);
|
|
608
|
+
|
|
609
|
+
try {
|
|
610
|
+
const res = await fetch('/api/chain');
|
|
611
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
612
|
+
_chainData = await res.json();
|
|
613
|
+
hide(`${type}-loading`);
|
|
614
|
+
renderListView(type);
|
|
615
|
+
} catch (err) {
|
|
616
|
+
hide(`${type}-loading`);
|
|
617
|
+
const errEl = el(`${type}-error`);
|
|
618
|
+
if (errEl) {
|
|
619
|
+
errEl.textContent = `Erro ao carregar ${type === 'adr' ? 'ADRs' : 'REQs'}: ${err.message}`;
|
|
620
|
+
errEl.classList.remove('hidden');
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function getListNodes(type) {
|
|
626
|
+
if (!_chainData) return [];
|
|
627
|
+
return (_chainData.nodes || []).filter(n => n.type === type);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Popula o <select> de status a partir dos valores distintos presentes na
|
|
631
|
+
// resposta do /api/chain. NUNCA hardcodar uma lista fixa de estados aqui:
|
|
632
|
+
// state é texto livre e uma lista fixa esconderia silenciosamente artefatos
|
|
633
|
+
// com status inesperado (ex.: "unknown").
|
|
634
|
+
function populateStatusFilter(type, nodes) {
|
|
635
|
+
const select = el(`${type}-status-filter`);
|
|
636
|
+
if (!select) return;
|
|
637
|
+
|
|
638
|
+
const current = select.value;
|
|
639
|
+
const states = Array.from(new Set(nodes.map(n => n.state || 'unknown'))).sort((a, b) => a.localeCompare(b));
|
|
640
|
+
|
|
641
|
+
select.innerHTML = '<option value="">Todos</option>';
|
|
642
|
+
states.forEach(state => {
|
|
643
|
+
const opt = document.createElement('option');
|
|
644
|
+
opt.value = state;
|
|
645
|
+
opt.textContent = state;
|
|
646
|
+
select.appendChild(opt);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
if (states.includes(current)) {
|
|
650
|
+
select.value = current;
|
|
651
|
+
} else {
|
|
652
|
+
select.value = '';
|
|
653
|
+
_listState[type].status = '';
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function renderListView(type) {
|
|
658
|
+
const nodes = getListNodes(type);
|
|
659
|
+
populateStatusFilter(type, nodes);
|
|
660
|
+
applyListFilters(type);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function onListSearch(type, value) {
|
|
664
|
+
_listState[type].search = value;
|
|
665
|
+
applyListFilters(type);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function onListStatusChange(type, value) {
|
|
669
|
+
_listState[type].status = value;
|
|
670
|
+
applyListFilters(type);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function applyListFilters(type) {
|
|
674
|
+
const nodes = getListNodes(type);
|
|
675
|
+
const { search, status } = _listState[type];
|
|
676
|
+
const normSearch = normalizeText(search);
|
|
677
|
+
|
|
678
|
+
const filtered = nodes.filter(n => {
|
|
679
|
+
if (status && (n.state || 'unknown') !== status) return false;
|
|
680
|
+
if (!normSearch) return true;
|
|
681
|
+
const haystack = normalizeText(`${n.title || ''} ${n.id || ''}`);
|
|
682
|
+
return haystack.includes(normSearch);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
renderListRows(type, filtered, nodes.length);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function renderListRows(type, filtered, total) {
|
|
689
|
+
const listEl = el(`${type}-list`);
|
|
690
|
+
const emptyEl = el(`${type}-empty`);
|
|
691
|
+
const countEl = el(`${type}-count`);
|
|
692
|
+
if (!listEl) return;
|
|
693
|
+
|
|
694
|
+
const typeLabel = type === 'adr' ? 'ADRs' : 'REQs';
|
|
695
|
+
listEl.innerHTML = '';
|
|
696
|
+
|
|
697
|
+
if (countEl) {
|
|
698
|
+
countEl.textContent = `${filtered.length} de ${total} ${typeLabel}`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (filtered.length === 0) {
|
|
702
|
+
if (emptyEl) {
|
|
703
|
+
emptyEl.textContent = total === 0
|
|
704
|
+
? `Nenhum ${type === 'adr' ? 'ADR' : 'REQ'} encontrado.`
|
|
705
|
+
: 'Nenhum item corresponde à busca/filtro atual.';
|
|
706
|
+
emptyEl.classList.remove('hidden');
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (emptyEl) emptyEl.classList.add('hidden');
|
|
712
|
+
|
|
713
|
+
filtered
|
|
714
|
+
.slice()
|
|
715
|
+
.sort((a, b) => (a.id || '').localeCompare(b.id || ''))
|
|
716
|
+
.forEach(node => listEl.appendChild(createListRow(node)));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function createListRow(node) {
|
|
720
|
+
const identifier = (node.id || '').split('/').pop();
|
|
721
|
+
const title = node.title || identifier;
|
|
722
|
+
const state = node.state || 'unknown';
|
|
723
|
+
|
|
724
|
+
const row = document.createElement('div');
|
|
725
|
+
row.className = 'list-row';
|
|
726
|
+
row.setAttribute('tabindex', '0');
|
|
727
|
+
row.setAttribute('role', 'button');
|
|
728
|
+
row.setAttribute('aria-label', `Abrir: ${title}`);
|
|
729
|
+
|
|
730
|
+
row.innerHTML = `
|
|
731
|
+
<span class="list-row-id">${escapeHtml(identifier)}</span>
|
|
732
|
+
<span class="list-row-title">${escapeHtml(title)}</span>
|
|
733
|
+
<span class="status-chip" data-state="${escapeHtml(state.toLowerCase())}">${escapeHtml(state)}</span>`;
|
|
734
|
+
|
|
735
|
+
row.addEventListener('click', () => openDrawer(node.id));
|
|
736
|
+
row.addEventListener('keydown', e => {
|
|
737
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
738
|
+
e.preventDefault();
|
|
739
|
+
openDrawer(node.id);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
return row;
|
|
744
|
+
}
|
|
745
|
+
|
|
575
746
|
// ─── View: Metrics ────────────────────────────────────────────────────────────
|
|
576
747
|
|
|
577
748
|
async function loadMetrics() {
|
|
@@ -699,6 +870,85 @@ function renderMetrics(data) {
|
|
|
699
870
|
|
|
700
871
|
// ─── Drawer lateral ───────────────────────────────────────────────────────────
|
|
701
872
|
|
|
873
|
+
// Allowlist do DOMPurify: cobre o markdown legítimo de ADRs/REQs/roadmaps
|
|
874
|
+
// (headings, listas ordenadas/não ordenadas, blockquote, code inline e em bloco,
|
|
875
|
+
// tabelas, links e checklists GFM `- [ ]`). Não inclui `img`, `iframe`, `style`,
|
|
876
|
+
// `on*` (event handlers) nem `script` — nenhum é usado pelo markdown do projeto
|
|
877
|
+
// e cada um ampliaria a superfície de ataque sem necessidade.
|
|
878
|
+
const MARKDOWN_SANITIZE_CONFIG = {
|
|
879
|
+
ALLOWED_TAGS: [
|
|
880
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
881
|
+
'p', 'br', 'hr',
|
|
882
|
+
'ul', 'ol', 'li',
|
|
883
|
+
'blockquote', 'pre', 'code',
|
|
884
|
+
'strong', 'em', 'b', 'i', 'del', 's', 'u',
|
|
885
|
+
'a',
|
|
886
|
+
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
|
887
|
+
'input',
|
|
888
|
+
],
|
|
889
|
+
ALLOWED_ATTR: ['href', 'title', 'class', 'id', 'align', 'type', 'checked', 'disabled'],
|
|
890
|
+
ALLOW_DATA_ATTR: false,
|
|
891
|
+
};
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Converte markdown em HTML e sanitiza o resultado antes de expor ao DOM.
|
|
895
|
+
* Fail-safe: se DOMPurify não estiver carregado (CDN fora do ar, SRI falhou,
|
|
896
|
+
* uso offline), NUNCA devolve HTML — devolve null para o chamador decidir
|
|
897
|
+
* como degradar (texto puro / erro). Não há caminho silencioso e inseguro.
|
|
898
|
+
*/
|
|
899
|
+
function renderMarkdownSafe(md) {
|
|
900
|
+
if (typeof DOMPurify === 'undefined') {
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
const html = marked.parse(md || '');
|
|
904
|
+
return DOMPurify.sanitize(html, MARKDOWN_SANITIZE_CONFIG);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Resolve um href de link markdown relativo ao documento atualmente aberto no
|
|
909
|
+
* drawer, devolvendo um caminho normalizado relativo à raiz do repositório.
|
|
910
|
+
*
|
|
911
|
+
* Todos os links `.md` encontrados em docs/ são relativos ao documento que os
|
|
912
|
+
* contém (nunca à raiz) — ver ADR-2026-08-01. Formas tratadas:
|
|
913
|
+
* - "./ARQUIVO.md" → mesma pasta do documento atual
|
|
914
|
+
* - "ARQUIVO.md" (sem prefixo) → mesma pasta do documento atual
|
|
915
|
+
* - "../dir/ARQUIVO.md" → sobe um nível a partir da pasta atual
|
|
916
|
+
* - "../../dir/ARQUIVO.md" → ".." encadeados, sobe N níveis
|
|
917
|
+
*
|
|
918
|
+
* Algoritmo: concatena o href com o dirname do documento atual e resolve os
|
|
919
|
+
* segmentos com uma pilha — "." é descartado, ".." remove o topo da pilha (ou
|
|
920
|
+
* é descartado silenciosamente se a pilha já estiver vazia, para nunca deixar
|
|
921
|
+
* o resultado escapar acima da raiz do repositório).
|
|
922
|
+
*
|
|
923
|
+
* Este helper NÃO é idempotente para um caminho já completo (ex.:
|
|
924
|
+
* "docs/adr/X.md") — se recebesse um como `href`, o resultado ficaria
|
|
925
|
+
* prefixado incorretamente pelo dirname do documento atual. A segurança aqui
|
|
926
|
+
* não vem do algoritmo, vem do isolamento do ponto de chamada: os três
|
|
927
|
+
* pontos de entrada que já passam caminho completo (card do Board, nó do
|
|
928
|
+
* grafo Chain, linha das listas ADR/REQ) chamam `openDrawer` diretamente e
|
|
929
|
+
* nunca passam pelo interceptador de links do markdown renderizado — o único
|
|
930
|
+
* lugar onde este helper é invocado. O levantamento do corpus em docs/ (ver
|
|
931
|
+
* ADR-2026-08-01) confirma zero links `.md` raiz-relativos dentro de
|
|
932
|
+
* documentos, então este helper nunca recebe, na prática, algo que já não
|
|
933
|
+
* seja relativo ao documento que o contém.
|
|
934
|
+
*/
|
|
935
|
+
function resolveRelativeMdHref(href, currentDocPath) {
|
|
936
|
+
const lastSlash = (currentDocPath || '').lastIndexOf('/');
|
|
937
|
+
const baseDir = lastSlash === -1 ? '' : currentDocPath.slice(0, lastSlash);
|
|
938
|
+
const combined = baseDir ? `${baseDir}/${href}` : href;
|
|
939
|
+
|
|
940
|
+
const stack = [];
|
|
941
|
+
combined.split('/').forEach(part => {
|
|
942
|
+
if (part === '' || part === '.') return;
|
|
943
|
+
if (part === '..') {
|
|
944
|
+
if (stack.length > 0) stack.pop();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
stack.push(part);
|
|
948
|
+
});
|
|
949
|
+
return stack.join('/');
|
|
950
|
+
}
|
|
951
|
+
|
|
702
952
|
async function openDrawer(path) {
|
|
703
953
|
if (!path) return;
|
|
704
954
|
_drawerPath = path;
|
|
@@ -732,7 +982,11 @@ async function openDrawer(path) {
|
|
|
732
982
|
|
|
733
983
|
try {
|
|
734
984
|
const res = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
|
|
735
|
-
if (!res.ok)
|
|
985
|
+
if (!res.ok) {
|
|
986
|
+
const httpErr = new Error(`HTTP ${res.status}`);
|
|
987
|
+
httpErr.status = res.status;
|
|
988
|
+
throw httpErr;
|
|
989
|
+
}
|
|
736
990
|
const raw = await res.text();
|
|
737
991
|
|
|
738
992
|
hide('drawer-loading');
|
|
@@ -743,23 +997,42 @@ async function openDrawer(path) {
|
|
|
743
997
|
renderFrontmatterTable(frontmatter);
|
|
744
998
|
}
|
|
745
999
|
|
|
746
|
-
// Renderizar markdown
|
|
1000
|
+
// Renderizar markdown (sanitizado — ver renderMarkdownSafe)
|
|
747
1001
|
if (mdEl) {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (!isExternal && isMdLink) {
|
|
757
|
-
a.addEventListener('click', e => {
|
|
758
|
-
e.preventDefault();
|
|
759
|
-
openDrawer(href);
|
|
760
|
-
});
|
|
1002
|
+
const safeHtml = renderMarkdownSafe(body || raw);
|
|
1003
|
+
if (safeHtml === null) {
|
|
1004
|
+
// Fail-safe: sem DOMPurify carregado, nunca renderizar HTML bruto.
|
|
1005
|
+
mdEl.textContent = body || raw;
|
|
1006
|
+
const errEl = el('drawer-error');
|
|
1007
|
+
if (errEl) {
|
|
1008
|
+
errEl.textContent = 'Aviso: sanitizador de HTML indisponível — conteúdo exibido como texto puro.';
|
|
1009
|
+
errEl.classList.remove('hidden');
|
|
761
1010
|
}
|
|
762
|
-
}
|
|
1011
|
+
} else {
|
|
1012
|
+
mdEl.innerHTML = safeHtml;
|
|
1013
|
+
|
|
1014
|
+
// Interceptar links internos .md
|
|
1015
|
+
mdEl.querySelectorAll('a[href]').forEach(a => {
|
|
1016
|
+
const href = a.getAttribute('href');
|
|
1017
|
+
if (!href) return;
|
|
1018
|
+
const isExternal = href.startsWith('http://') || href.startsWith('https://');
|
|
1019
|
+
// Um link pode carregar uma âncora de seção (ex.: "outro.md#secao").
|
|
1020
|
+
// O drawer não tem conceito de rolagem para âncora dentro do markdown
|
|
1021
|
+
// renderizado, então a âncora é descartada na resolução — apenas o
|
|
1022
|
+
// caminho do arquivo é usado para decidir se é um link .md e para
|
|
1023
|
+
// montar o caminho a buscar.
|
|
1024
|
+
const hashIndex = href.indexOf('#');
|
|
1025
|
+
const hrefPath = hashIndex === -1 ? href : href.slice(0, hashIndex);
|
|
1026
|
+
const isMdLink = hrefPath.endsWith('.md');
|
|
1027
|
+
if (!isExternal && isMdLink) {
|
|
1028
|
+
const resolved = resolveRelativeMdHref(hrefPath, _drawerPath);
|
|
1029
|
+
a.addEventListener('click', e => {
|
|
1030
|
+
e.preventDefault();
|
|
1031
|
+
openDrawer(resolved);
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
763
1036
|
}
|
|
764
1037
|
|
|
765
1038
|
show('drawer-content');
|
|
@@ -767,7 +1040,11 @@ async function openDrawer(path) {
|
|
|
767
1040
|
hide('drawer-loading');
|
|
768
1041
|
const errEl = el('drawer-error');
|
|
769
1042
|
if (errEl) {
|
|
770
|
-
|
|
1043
|
+
if (err.status === 403) {
|
|
1044
|
+
errEl.textContent = `Arquivo fora dos diretórios permitidos: ${path}`;
|
|
1045
|
+
} else {
|
|
1046
|
+
errEl.textContent = `Erro ao carregar arquivo: ${err.message}`;
|
|
1047
|
+
}
|
|
771
1048
|
errEl.classList.remove('hidden');
|
|
772
1049
|
}
|
|
773
1050
|
}
|
|
@@ -4,16 +4,28 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>trackfw — dashboard</title>
|
|
7
|
-
<!-- Tailwind CSS CDN
|
|
7
|
+
<!-- Tailwind CSS CDN: URL não-versionada (JIT em runtime), HTTP/2 302 — sem integrity de propósito.
|
|
8
|
+
Um hash fixo aqui quebraria o dashboard inteiro no próximo release deles. Migrar para uma
|
|
9
|
+
build versionada exige auditoria visual completa e é escopo de REQ própria. -->
|
|
8
10
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
9
|
-
<!-- HTMX -->
|
|
10
|
-
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
|
11
11
|
<!-- marked.js -->
|
|
12
|
-
<script
|
|
12
|
+
<script
|
|
13
|
+
src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"
|
|
14
|
+
integrity="sha384-NNQgBjjuhtXzPmmy4gurS5X7P4uTt1DThyevz4Ua0IVK5+kazYQI1W27JHjbbxQz"
|
|
15
|
+
crossorigin="anonymous"
|
|
16
|
+
referrerpolicy="no-referrer"></script>
|
|
13
17
|
<!-- Chart.js -->
|
|
14
|
-
<script
|
|
18
|
+
<script
|
|
19
|
+
src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
|
|
20
|
+
integrity="sha384-NrKB+u6Ts6AtkIhwPixiKTzgSKNblyhlk0Sohlgar9UHUBzai/sgnNNWWd291xqt"
|
|
21
|
+
crossorigin="anonymous"
|
|
22
|
+
referrerpolicy="no-referrer"></script>
|
|
15
23
|
<!-- D3.js -->
|
|
16
|
-
<script
|
|
24
|
+
<script
|
|
25
|
+
src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
|
|
26
|
+
integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i"
|
|
27
|
+
crossorigin="anonymous"
|
|
28
|
+
referrerpolicy="no-referrer"></script>
|
|
17
29
|
<link rel="stylesheet" href="/static/style.css" />
|
|
18
30
|
</head>
|
|
19
31
|
<body class="bg-gray-50 text-gray-900 min-h-screen flex flex-col">
|
|
@@ -40,6 +52,18 @@
|
|
|
40
52
|
class="tab-btn px-4 py-1.5 rounded text-sm font-medium transition-colors"
|
|
41
53
|
aria-pressed="false"
|
|
42
54
|
>Metrics</button>
|
|
55
|
+
<button
|
|
56
|
+
id="tab-adr"
|
|
57
|
+
onclick="switchView('adr')"
|
|
58
|
+
class="tab-btn px-4 py-1.5 rounded text-sm font-medium transition-colors"
|
|
59
|
+
aria-pressed="false"
|
|
60
|
+
>ADRs</button>
|
|
61
|
+
<button
|
|
62
|
+
id="tab-req"
|
|
63
|
+
onclick="switchView('req')"
|
|
64
|
+
class="tab-btn px-4 py-1.5 rounded text-sm font-medium transition-colors"
|
|
65
|
+
aria-pressed="false"
|
|
66
|
+
>REQs</button>
|
|
43
67
|
</nav>
|
|
44
68
|
</header>
|
|
45
69
|
|
|
@@ -175,6 +199,76 @@
|
|
|
175
199
|
</div>
|
|
176
200
|
</section>
|
|
177
201
|
|
|
202
|
+
<!-- View: ADRs -->
|
|
203
|
+
<section id="view-adr" class="view-section w-full hidden p-6 overflow-auto" aria-label="Lista de ADRs">
|
|
204
|
+
<div class="flex items-center gap-3 flex-wrap mb-4">
|
|
205
|
+
<input
|
|
206
|
+
type="search"
|
|
207
|
+
id="adr-search"
|
|
208
|
+
oninput="onListSearch('adr', this.value)"
|
|
209
|
+
placeholder="Buscar por título ou identificador..."
|
|
210
|
+
class="text-sm border border-gray-300 rounded px-3 py-1.5 bg-white flex-1 min-w-[220px]"
|
|
211
|
+
aria-label="Buscar ADRs por título ou identificador"
|
|
212
|
+
/>
|
|
213
|
+
<label for="adr-status-filter" class="text-sm font-medium text-gray-600 sr-only">Filtrar por status</label>
|
|
214
|
+
<select
|
|
215
|
+
id="adr-status-filter"
|
|
216
|
+
onchange="onListStatusChange('adr', this.value)"
|
|
217
|
+
class="text-sm border border-gray-300 rounded px-2 py-1.5 bg-white"
|
|
218
|
+
aria-label="Filtrar ADRs por status"
|
|
219
|
+
>
|
|
220
|
+
<option value="">Todos</option>
|
|
221
|
+
</select>
|
|
222
|
+
<span id="adr-count" class="text-xs text-gray-500 ml-auto" aria-live="polite"></span>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div id="adr-loading" class="flex items-center gap-2 text-gray-500 text-sm">
|
|
226
|
+
<svg class="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
227
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
228
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
229
|
+
</svg>
|
|
230
|
+
Carregando ADRs...
|
|
231
|
+
</div>
|
|
232
|
+
<div id="adr-error" class="hidden text-red-600 text-sm mt-4" role="alert"></div>
|
|
233
|
+
<div id="adr-empty" class="hidden text-sm text-gray-400 text-center py-8"></div>
|
|
234
|
+
<div id="adr-list" class="list-view-container"></div>
|
|
235
|
+
</section>
|
|
236
|
+
|
|
237
|
+
<!-- View: REQs -->
|
|
238
|
+
<section id="view-req" class="view-section w-full hidden p-6 overflow-auto" aria-label="Lista de REQs">
|
|
239
|
+
<div class="flex items-center gap-3 flex-wrap mb-4">
|
|
240
|
+
<input
|
|
241
|
+
type="search"
|
|
242
|
+
id="req-search"
|
|
243
|
+
oninput="onListSearch('req', this.value)"
|
|
244
|
+
placeholder="Buscar por título ou identificador..."
|
|
245
|
+
class="text-sm border border-gray-300 rounded px-3 py-1.5 bg-white flex-1 min-w-[220px]"
|
|
246
|
+
aria-label="Buscar REQs por título ou identificador"
|
|
247
|
+
/>
|
|
248
|
+
<label for="req-status-filter" class="text-sm font-medium text-gray-600 sr-only">Filtrar por status</label>
|
|
249
|
+
<select
|
|
250
|
+
id="req-status-filter"
|
|
251
|
+
onchange="onListStatusChange('req', this.value)"
|
|
252
|
+
class="text-sm border border-gray-300 rounded px-2 py-1.5 bg-white"
|
|
253
|
+
aria-label="Filtrar REQs por status"
|
|
254
|
+
>
|
|
255
|
+
<option value="">Todos</option>
|
|
256
|
+
</select>
|
|
257
|
+
<span id="req-count" class="text-xs text-gray-500 ml-auto" aria-live="polite"></span>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div id="req-loading" class="flex items-center gap-2 text-gray-500 text-sm">
|
|
261
|
+
<svg class="animate-spin h-4 w-4 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
262
|
+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
263
|
+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
|
264
|
+
</svg>
|
|
265
|
+
Carregando REQs...
|
|
266
|
+
</div>
|
|
267
|
+
<div id="req-error" class="hidden text-red-600 text-sm mt-4" role="alert"></div>
|
|
268
|
+
<div id="req-empty" class="hidden text-sm text-gray-400 text-center py-8"></div>
|
|
269
|
+
<div id="req-list" class="list-view-container"></div>
|
|
270
|
+
</section>
|
|
271
|
+
|
|
178
272
|
</main>
|
|
179
273
|
|
|
180
274
|
<!-- Overlay do drawer -->
|
|
@@ -233,6 +327,12 @@
|
|
|
233
327
|
</div>
|
|
234
328
|
</aside>
|
|
235
329
|
|
|
330
|
+
<!-- DOMPurify (sanitização de HTML do drawer) -->
|
|
331
|
+
<script
|
|
332
|
+
src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"
|
|
333
|
+
integrity="sha384-piCcpDdJ7qVeK4Tv8Z6Hpcr3ZBIgP16TxQTPVfsLFdZ5uDgwc3Y8Ho7oUnqf12qu"
|
|
334
|
+
crossorigin="anonymous"
|
|
335
|
+
referrerpolicy="no-referrer"></script>
|
|
236
336
|
<script src="/static/app.js"></script>
|
|
237
337
|
</body>
|
|
238
338
|
</html>
|
|
@@ -205,6 +205,85 @@
|
|
|
205
205
|
animation: attentionRing 2s ease-in-out infinite;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/* ─── Views de lista (ADRs / REQs) ─────────────────────────────────────────── */
|
|
209
|
+
.list-view-container {
|
|
210
|
+
display: flex;
|
|
211
|
+
flex-direction: column;
|
|
212
|
+
gap: 0.4rem;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.list-row {
|
|
216
|
+
display: grid;
|
|
217
|
+
grid-template-columns: 220px 1fr auto;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 0.75rem;
|
|
220
|
+
background-color: #f9fafb;
|
|
221
|
+
border: 1px solid #e5e7eb;
|
|
222
|
+
border-radius: 6px;
|
|
223
|
+
padding: 0.6rem 0.9rem;
|
|
224
|
+
cursor: pointer;
|
|
225
|
+
transition: box-shadow 0.15s ease, transform 0.1s ease, background-color 0.15s ease;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.list-row:hover {
|
|
229
|
+
background-color: #ffffff;
|
|
230
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
|
231
|
+
transform: translateY(-1px);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.list-row:focus-visible {
|
|
235
|
+
outline: 2px solid #3b82f6;
|
|
236
|
+
outline-offset: 2px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.list-row-id {
|
|
240
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
241
|
+
font-size: 0.78rem;
|
|
242
|
+
color: #6b7280;
|
|
243
|
+
white-space: nowrap;
|
|
244
|
+
overflow: hidden;
|
|
245
|
+
text-overflow: ellipsis;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.list-row-title {
|
|
249
|
+
font-size: 0.85rem;
|
|
250
|
+
font-weight: 500;
|
|
251
|
+
color: #1f2937;
|
|
252
|
+
overflow: hidden;
|
|
253
|
+
text-overflow: ellipsis;
|
|
254
|
+
white-space: nowrap;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.status-chip {
|
|
258
|
+
display: inline-block;
|
|
259
|
+
font-size: 0.72rem;
|
|
260
|
+
font-weight: 600;
|
|
261
|
+
padding: 0.15rem 0.55rem;
|
|
262
|
+
border-radius: 999px;
|
|
263
|
+
background-color: #e5e7eb;
|
|
264
|
+
color: #374151;
|
|
265
|
+
white-space: nowrap;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* Cores conhecidas de status (fallback cinza acima cobre qualquer texto livre) */
|
|
269
|
+
.status-chip[data-state="accepted"] { background-color: #dcfce7; color: #15803d; }
|
|
270
|
+
.status-chip[data-state="proposed"] { background-color: #fef3c7; color: #92400e; }
|
|
271
|
+
.status-chip[data-state="done"] { background-color: #dbeafe; color: #1d4ed8; }
|
|
272
|
+
.status-chip[data-state="closed"] { background-color: #f3f4f6; color: #374151; }
|
|
273
|
+
.status-chip[data-state="unknown"] { background-color: #fee2e2; color: #991b1b; }
|
|
274
|
+
|
|
275
|
+
@media (max-width: 640px) {
|
|
276
|
+
.list-row {
|
|
277
|
+
grid-template-columns: 1fr auto;
|
|
278
|
+
grid-template-areas:
|
|
279
|
+
"id status"
|
|
280
|
+
"title title";
|
|
281
|
+
}
|
|
282
|
+
.list-row-id { grid-area: id; }
|
|
283
|
+
.list-row-title { grid-area: title; }
|
|
284
|
+
.status-chip { grid-area: status; justify-self: end; }
|
|
285
|
+
}
|
|
286
|
+
|
|
208
287
|
/* Banner de atenção (entre header e main) */
|
|
209
288
|
.attention-banner {
|
|
210
289
|
display: flex;
|
package/src/validator/index.js
CHANGED
|
@@ -754,7 +754,7 @@ function validateRefTargetsExist() {
|
|
|
754
754
|
const content = readFileForRule('ref_targets_exist', path.join(dir, name), warnings)
|
|
755
755
|
if (content === null) continue
|
|
756
756
|
const ref = extractRefPath(content, 'REQ')
|
|
757
|
-
if (ref && !referenceExists(ref
|
|
757
|
+
if (ref && !referenceExists(ref)) {
|
|
758
758
|
warnings.push(`roadmap "${name}" links to REQ "${ref}" which does not exist`)
|
|
759
759
|
}
|
|
760
760
|
}
|
|
@@ -766,11 +766,11 @@ function validateRefTargetsExist() {
|
|
|
766
766
|
if (content === null) continue
|
|
767
767
|
const name = path.basename(filePath)
|
|
768
768
|
const adrRef = extractRefPath(content, 'ADR')
|
|
769
|
-
if (adrRef && !referenceExists(adrRef
|
|
769
|
+
if (adrRef && !referenceExists(adrRef)) {
|
|
770
770
|
warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
|
|
771
771
|
}
|
|
772
772
|
const roadmapRef = extractRefPath(content, 'Roadmap')
|
|
773
|
-
if (roadmapRef && !referenceExists(roadmapRef
|
|
773
|
+
if (roadmapRef && !referenceExists(roadmapRef)) {
|
|
774
774
|
warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
|
|
775
775
|
}
|
|
776
776
|
}
|
|
@@ -778,7 +778,7 @@ function validateRefTargetsExist() {
|
|
|
778
778
|
return warnings
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
-
function referenceExists(ref
|
|
781
|
+
function referenceExists(ref) {
|
|
782
782
|
const expandedRef = config.expandPath ? config.expandPath(ref) : ref
|
|
783
783
|
if (fs.existsSync(expandedRef)) return true
|
|
784
784
|
return false
|