trackfw 6.10.0 → 7.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/README.md +0 -3
- package/bin/trackfw +38 -1
- package/package.json +1 -1
- package/src/branch/prune.js +450 -0
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +59 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +75 -0
- package/src/commands/doctor.js +32 -0
- package/src/commands/index.js +23 -4
- package/src/commands/integrations.js +23 -1
- package/src/commands/serve.js +52 -4
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +320 -12
- package/src/commands/update.js +3 -1
- package/src/commit/runner.js +352 -0
- package/src/config/index.js +27 -0
- package/src/generators/hooks.js +925 -6
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +0 -21
- package/src/i18n/locales/es-ES.json +0 -21
- package/src/i18n/locales/pt-BR.json +0 -21
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +133 -19
- package/src/lib/fatal-error.js +64 -0
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +259 -95
- package/src/thirdparty/fetch.js +140 -0
- package/src/thirdparty/markers.js +207 -0
- package/src/thirdparty/provenance.js +114 -0
- package/src/thirdparty/quarantine.js +113 -0
- package/src/thirdparty/references.js +194 -0
- package/src/validator/index.js +1060 -27
- package/src/commands/plugins.js +0 -184
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// changelog — parsing e extração de seções do CHANGELOG.md no formato
|
|
4
|
+
// Keep a Changelog (https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
// Port 1:1 de internal/changelog/changelog.go — manter em sincronia.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
|
|
10
|
+
// sectionHeaderRE casa cabeçalhos de seção no formato "## [x.y.z] - YYYY-MM-DD"
|
|
11
|
+
// ou "## [Unreleased]" (sem data).
|
|
12
|
+
const sectionHeaderRE = /^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* parseSections — separa o conteúdo de um CHANGELOG.md em seções, uma por
|
|
16
|
+
* cabeçalho "## [...]" encontrado. Texto antes da primeira seção (título do
|
|
17
|
+
* arquivo, preâmbulo) é descartado.
|
|
18
|
+
* @param {string} content
|
|
19
|
+
* @returns {Array<{version: string, date: string, body: string}>}
|
|
20
|
+
*/
|
|
21
|
+
function parseSections(content) {
|
|
22
|
+
const lines = content.split('\n')
|
|
23
|
+
|
|
24
|
+
const sections = []
|
|
25
|
+
let current = null
|
|
26
|
+
let bodyLines = []
|
|
27
|
+
|
|
28
|
+
const flush = () => {
|
|
29
|
+
if (current !== null) {
|
|
30
|
+
current.body = bodyLines.join('\n')
|
|
31
|
+
sections.push(current)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const m = sectionHeaderRE.exec(line)
|
|
37
|
+
if (m !== null) {
|
|
38
|
+
flush()
|
|
39
|
+
current = { version: m[1], date: m[2] || '', body: '' }
|
|
40
|
+
bodyLines = []
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (current !== null) {
|
|
44
|
+
bodyLines.push(line)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
flush()
|
|
48
|
+
|
|
49
|
+
return sections
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* firstSection — retorna a primeira seção da lista.
|
|
54
|
+
* Erro se a lista vier vazia.
|
|
55
|
+
* @param {Array<{version: string, date: string, body: string}>} sections
|
|
56
|
+
* @returns {{version: string, date: string, body: string}}
|
|
57
|
+
*/
|
|
58
|
+
function firstSection(sections) {
|
|
59
|
+
if (sections.length === 0) {
|
|
60
|
+
throw new Error('CHANGELOG.md has no version sections')
|
|
61
|
+
}
|
|
62
|
+
return sections[0]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* findVersion — busca a seção com version igual ao argumento, normalizando um
|
|
67
|
+
* prefixo "v"/"V" opcional no argumento do usuário antes de comparar.
|
|
68
|
+
* @param {Array<{version: string, date: string, body: string}>} sections
|
|
69
|
+
* @param {string} version
|
|
70
|
+
* @returns {{version: string, date: string, body: string}}
|
|
71
|
+
*/
|
|
72
|
+
function findVersion(sections, version) {
|
|
73
|
+
let normalized = version
|
|
74
|
+
if (normalized.length > 0 && (normalized[0] === 'v' || normalized[0] === 'V')) {
|
|
75
|
+
normalized = normalized.slice(1)
|
|
76
|
+
}
|
|
77
|
+
for (const s of sections) {
|
|
78
|
+
if (s.version === normalized || s.version === version) {
|
|
79
|
+
return s
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`version "${version}" not found in CHANGELOG.md`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* formatSection — reconstrói o texto formatado de uma seção, reproduzindo o
|
|
87
|
+
* cabeçalho original.
|
|
88
|
+
* @param {{version: string, date: string, body: string}} section
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function formatSection(section) {
|
|
92
|
+
const dateSuffix = section.date !== '' ? ` - ${section.date}` : ''
|
|
93
|
+
const body = section.body.replace(/^\n+/, '')
|
|
94
|
+
return `## [${section.version}]${dateSuffix}\n\n${body.replace(/\n+$/, '') + '\n'}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* read — lê o CHANGELOG.md na raiz informada.
|
|
99
|
+
* @param {string} root
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function read(root) {
|
|
103
|
+
const filePath = path.join(root, 'CHANGELOG.md')
|
|
104
|
+
try {
|
|
105
|
+
return fs.readFileSync(filePath, 'utf8')
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err.code === 'ENOENT') {
|
|
108
|
+
throw new Error('CHANGELOG.md not found — nothing to show')
|
|
109
|
+
}
|
|
110
|
+
throw err
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { parseSections, firstSection, findVersion, formatSection, read }
|
package/src/commands/branch.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const { Command } = require('commander')
|
|
9
9
|
const { runBranchNew } = require('../branch/runner')
|
|
10
|
+
const { runBranchPrune } = require('../branch/prune')
|
|
10
11
|
|
|
11
12
|
function createBranchCommand() {
|
|
12
13
|
const cmd = new Command('branch')
|
|
@@ -15,22 +16,25 @@ function createBranchCommand() {
|
|
|
15
16
|
const newCmd = new Command('new')
|
|
16
17
|
newCmd
|
|
17
18
|
.description(
|
|
18
|
-
'Create a feat/fix/refactor branch
|
|
19
|
+
'Create a feat/fix/refactor/chore/docs branch; feat/fix/refactor gated on a matching REQ + roadmap already in wip/ or done/\n\n' +
|
|
19
20
|
'trackfw branch new moves the branch_has_wip_roadmap governance gate (already enforced\n' +
|
|
20
21
|
"by 'trackfw validate' and 'trackfw ship') to before branch creation, instead of after:\n\n" +
|
|
21
|
-
' 1. Validates <type> is one of feat, fix, refactor and <slug> is non-empty.\n' +
|
|
22
|
-
' 2.
|
|
23
|
-
" logic 'trackfw validate' already uses (normalized slug
|
|
24
|
-
"
|
|
25
|
-
" governance orientation message 'trackfw validate'
|
|
26
|
-
"
|
|
27
|
-
'
|
|
22
|
+
' 1. Validates <type> is one of feat, fix, refactor, chore, docs and <slug> is non-empty.\n' +
|
|
23
|
+
' 2. For feat, fix, refactor: checks whether a roadmap in wip/ or done/ matches the given\n' +
|
|
24
|
+
" slug — the exact matching logic 'trackfw validate' already uses (normalized slug,\n" +
|
|
25
|
+
" filename contains match). Without a match: blocks — 'git checkout -b' is never\n" +
|
|
26
|
+
" executed — and prints the same governance orientation message 'trackfw validate'\n" +
|
|
27
|
+
" already prints for this rule.\n" +
|
|
28
|
+
" 3. For chore, docs: housekeeping types already treated as roadmap-exempt by 'trackfw ship'\n" +
|
|
29
|
+
" and 'trackfw commit' — the branch is created without the roadmap gate.\n" +
|
|
30
|
+
" 4. With a match (or for chore/docs): runs 'git checkout -b <type>/<slug>', propagating\n" +
|
|
31
|
+
" Git's own output and exit status literally.\n\n" +
|
|
28
32
|
'Create the governance artifacts first if this blocks you:\n' +
|
|
29
33
|
' trackfw req new "title"\n' +
|
|
30
34
|
' trackfw roadmap new "title"\n' +
|
|
31
35
|
' trackfw roadmap move <name> wip'
|
|
32
36
|
)
|
|
33
|
-
.argument('<spec>', '<type>/<slug>, type in feat, fix, refactor')
|
|
37
|
+
.argument('<spec>', '<type>/<slug>, type in feat, fix, refactor, chore, docs')
|
|
34
38
|
.option('--dry-run', 'Report whether the branch would be created or blocked, without executing git', false)
|
|
35
39
|
.action((spec, options) => {
|
|
36
40
|
const exitCode = runBranchNew(spec, !!options.dryRun, {})
|
|
@@ -40,6 +44,52 @@ function createBranchCommand() {
|
|
|
40
44
|
})
|
|
41
45
|
|
|
42
46
|
cmd.addCommand(newCmd)
|
|
47
|
+
|
|
48
|
+
const pruneCmd = new Command('prune')
|
|
49
|
+
pruneCmd
|
|
50
|
+
.description('Report (and, with --apply, delete) local branches already integrated into origin/main')
|
|
51
|
+
.addHelpText(
|
|
52
|
+
'after',
|
|
53
|
+
'\n' +
|
|
54
|
+
'trackfw branch prune automates the "one active branch at a time" check documented in\n' +
|
|
55
|
+
'CLAUDE.md §1 — it does not remove human judgment from every case: a branch whose only\n' +
|
|
56
|
+
'remaining divergence is doc/config files is flagged for manual review, never deleted\n' +
|
|
57
|
+
'automatically.\n\n' +
|
|
58
|
+
"A best-effort 'git fetch origin --prune' runs first. Failure (offline, no remote) is\n" +
|
|
59
|
+
'non-blocking: a warning is printed and evaluation proceeds against the local origin/main\n' +
|
|
60
|
+
'ref, whatever its state. A stale origin/main only ever makes the result MORE conservative —\n' +
|
|
61
|
+
'it can miss a branch that was in fact integrated since the last fetch (reporting it kept\n' +
|
|
62
|
+
'when a fresh fetch would show it deletable), but it never reports one as deletable that a\n' +
|
|
63
|
+
'fresh fetch would show as pending.\n\n' +
|
|
64
|
+
"Decides integration with the touched-files heuristic, NOT git's own ancestry check (which\n" +
|
|
65
|
+
'always refuses squash-merged branches) and NOT a naive bidirectional diff against origin/main\n' +
|
|
66
|
+
'(which false-positives on a branch that is merged but stale, once main has advanced further):\n\n' +
|
|
67
|
+
' mb = git merge-base origin/main <branch>\n' +
|
|
68
|
+
' touched = git diff --name-only mb <branch> (what the branch touched)\n' +
|
|
69
|
+
' diverg = git diff --name-only origin/main <branch> -- touched (what still differs there)\n\n' +
|
|
70
|
+
'touched empty -> integrated (safe to delete)\n' +
|
|
71
|
+
'diverg empty -> integrated (safe to delete) -- squash-merged, stale, main advanced since\n' +
|
|
72
|
+
'diverg doc/config only -> flagged for review (kept; probable housekeeping, confirm and delete manually)\n' +
|
|
73
|
+
'otherwise -> kept, with the diverging files named\n\n' +
|
|
74
|
+
'Every local branch is reported, always, with its decision and reason. The current branch, any\n' +
|
|
75
|
+
'branch checked out in another worktree, and the default branch (main) are always kept and\n' +
|
|
76
|
+
'never evaluated for deletion. Without origin/main resolvable at all (offline with no prior\n' +
|
|
77
|
+
'fetch ever having run, or no remote configured), the whole command refuses and deletes\n' +
|
|
78
|
+
'nothing.\n\n' +
|
|
79
|
+
'--dry-run is the default: without --apply, nothing is ever deleted, even the clearly\n' +
|
|
80
|
+
"integrated. Deletion tries 'git branch -d' first — confirming the integration via git's own\n" +
|
|
81
|
+
"ancestry check too, when possible — and falls back to 'git branch -D' only when -d refuses,\n" +
|
|
82
|
+
'the expected case for squash-merged branches, which never have fast-forward ancestry with main.'
|
|
83
|
+
)
|
|
84
|
+
.option('--apply', 'Actually delete branches decided as integrated (default: report only, delete nothing)', false)
|
|
85
|
+
.action((options) => {
|
|
86
|
+
const exitCode = runBranchPrune(!!options.apply, {})
|
|
87
|
+
if (exitCode !== 0) {
|
|
88
|
+
process.exit(exitCode)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
cmd.addCommand(pruneCmd)
|
|
43
93
|
return cmd
|
|
44
94
|
}
|
|
45
95
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { Command } = require('commander')
|
|
3
|
+
const { read, parseSections, firstSection, findVersion, formatSection } = require('../changelog')
|
|
4
|
+
|
|
5
|
+
const cmd = new Command('changelog')
|
|
6
|
+
cmd.description('Show entries from CHANGELOG.md')
|
|
7
|
+
// enablePositionalOptions escopa a flag local "--version" a este subcomando,
|
|
8
|
+
// evitando que o root a intercepte como a flag global "trackfw --version".
|
|
9
|
+
cmd.enablePositionalOptions()
|
|
10
|
+
cmd.option('--version <x.y.z>', 'Show a specific version section')
|
|
11
|
+
cmd.option('--all', 'Show the entire CHANGELOG.md')
|
|
12
|
+
cmd.action(async (opts) => {
|
|
13
|
+
try {
|
|
14
|
+
const root = process.cwd()
|
|
15
|
+
const content = read(root)
|
|
16
|
+
if (opts.all) {
|
|
17
|
+
process.stdout.write(content)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
const sections = parseSections(content)
|
|
21
|
+
const section = opts.version ? findVersion(sections, opts.version) : firstSection(sections)
|
|
22
|
+
process.stdout.write(formatSection(section))
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error(`Error: ${err.message}`)
|
|
25
|
+
process.exitCode = 1
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
module.exports = cmd
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// trackfw commit -m "<message>"
|
|
4
|
+
//
|
|
5
|
+
// CLI wiring for the `trackfw commit` command. The testable logic lives in
|
|
6
|
+
// ../commit/runner.js (mirrors the branch.js / branch/runner.js split already used in this CLI).
|
|
7
|
+
|
|
8
|
+
const { Command } = require('commander')
|
|
9
|
+
const { runCommit, buildSuggestedMessage } = require('../commit/runner')
|
|
10
|
+
|
|
11
|
+
function createCommitCommand() {
|
|
12
|
+
const cmd = new Command('commit')
|
|
13
|
+
cmd
|
|
14
|
+
.description(
|
|
15
|
+
"trackfw commit is the missing intermediate step between raw 'git commit' and\n" +
|
|
16
|
+
"'trackfw ship': it commits staged changes directly, but blocks the commit before it happens\n" +
|
|
17
|
+
'when governance is missing, instead of letting it land and only catching it later:\n\n' +
|
|
18
|
+
" 1. On 'main'/'master': always blocked — commit directly on the default branch is never\n" +
|
|
19
|
+
' permitted.\n' +
|
|
20
|
+
' 2. On a feat/fix/refactor branch: requires a roadmap matching the branch slug already in\n' +
|
|
21
|
+
" wip/ or done/ — the exact matching logic 'trackfw branch new' and 'trackfw validate'\n" +
|
|
22
|
+
' already use. Without a match, blocks with the same governance orientation message.\n' +
|
|
23
|
+
' 3. On any other branch (e.g. doc/housekeeping branches): allowed without requiring a\n' +
|
|
24
|
+
' roadmap — a warning is logged, but the commit proceeds.\n' +
|
|
25
|
+
" 4. When allowed: runs 'git commit -m <message>', propagating Git's own output and exit\n" +
|
|
26
|
+
' status literally.\n\n' +
|
|
27
|
+
"'--suggest' takes a completely separate path: it prints a heuristic Conventional Commits\n" +
|
|
28
|
+
"skeleton built from 'git diff --cached --name-status' (type + staged file list) and exits\n" +
|
|
29
|
+
'without ever committing — no LLM call, just a structural heuristic. It is not a\n' +
|
|
30
|
+
'ready-to-use message; review and edit before using it with -m. When \'--suggest\' is set,\n' +
|
|
31
|
+
"'-m' (if also passed) is ignored and no commit ever happens.\n\n" +
|
|
32
|
+
'Create the governance artifacts first if this blocks you:\n' +
|
|
33
|
+
' trackfw req new "title"\n' +
|
|
34
|
+
' trackfw roadmap new "title"\n' +
|
|
35
|
+
' trackfw roadmap move <name> wip'
|
|
36
|
+
)
|
|
37
|
+
.option('-m, --message <msg>', 'Commit message (required)')
|
|
38
|
+
.option('--suggest', 'Print a heuristic Conventional Commits message skeleton from staged files and exit without committing (ignores -m)')
|
|
39
|
+
.action((options) => {
|
|
40
|
+
if (options.suggest) {
|
|
41
|
+
try {
|
|
42
|
+
const suggestion = buildSuggestedMessage({})
|
|
43
|
+
process.stdout.write(suggestion + '\n')
|
|
44
|
+
return
|
|
45
|
+
} catch (err) {
|
|
46
|
+
process.stderr.write(err.message + '\n')
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const exitCode = runCommit(options.message || '', {})
|
|
52
|
+
if (exitCode !== 0) {
|
|
53
|
+
process.exit(exitCode)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return cmd
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = createCommitCommand()
|
package/src/commands/discover.js
CHANGED
|
@@ -22,6 +22,11 @@ function scan(rootDir) {
|
|
|
22
22
|
hookFramework: 'none',
|
|
23
23
|
ciSystem: 'none',
|
|
24
24
|
forge: '',
|
|
25
|
+
// suggestedTestFramework é uma sugestão best-effort (nunca erro, '' quando nada bate) baseada
|
|
26
|
+
// em arquivos de configuração presentes na raiz do projeto. É apenas impressa como sugestão
|
|
27
|
+
// pelo comando `discover` — nunca escrita automaticamente em trackfw.yaml (a convenção de
|
|
28
|
+
// agent_conventions deve ser sempre declarada pelo time, não inferida).
|
|
29
|
+
suggestedTestFramework: '',
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
// trackfw.yaml
|
|
@@ -110,6 +115,9 @@ function scan(rootDir) {
|
|
|
110
115
|
r.ciSystem = 'none';
|
|
111
116
|
}
|
|
112
117
|
|
|
118
|
+
// Suggested test framework — best-effort heuristic, never an error.
|
|
119
|
+
r.suggestedTestFramework = detectTestFramework(rootDir);
|
|
120
|
+
|
|
113
121
|
// Forge detection — reuse forge/resolve.js (no duplicate parse).
|
|
114
122
|
// gitRemoteURL returns '' on any error; CI detection is filesystem-based.
|
|
115
123
|
try {
|
|
@@ -338,6 +346,60 @@ function countMD(dir) {
|
|
|
338
346
|
return n;
|
|
339
347
|
}
|
|
340
348
|
|
|
349
|
+
// detectTestFramework é uma heurística best-effort para sugerir um framework de teste com base
|
|
350
|
+
// em arquivos de configuração presentes na raiz do projeto. Nunca retorna erro — retorna '' quando
|
|
351
|
+
// nenhum arquivo-gatilho é encontrado. Ordem de precedência: jest, vitest, pytest, go test.
|
|
352
|
+
function detectTestFramework(rootDir) {
|
|
353
|
+
if (isFile(path.join(rootDir, 'jest.config.js')) || isFile(path.join(rootDir, 'jest.config.ts'))) {
|
|
354
|
+
return 'jest';
|
|
355
|
+
}
|
|
356
|
+
if (isFile(path.join(rootDir, 'vitest.config.js')) || isFile(path.join(rootDir, 'vitest.config.ts'))) {
|
|
357
|
+
return 'vitest';
|
|
358
|
+
}
|
|
359
|
+
if (isFile(path.join(rootDir, 'pytest.ini'))) {
|
|
360
|
+
return 'pytest';
|
|
361
|
+
}
|
|
362
|
+
if (hasFileWithSubstring(path.join(rootDir, 'pyproject.toml'), '[tool.pytest')) {
|
|
363
|
+
return 'pytest';
|
|
364
|
+
}
|
|
365
|
+
if (hasFileWithSubstring(path.join(rootDir, 'setup.cfg'), '[tool:pytest]')) {
|
|
366
|
+
return 'pytest';
|
|
367
|
+
}
|
|
368
|
+
if (isFile(path.join(rootDir, 'go.mod')) && hasGoTestFile(rootDir)) {
|
|
369
|
+
return 'go test';
|
|
370
|
+
}
|
|
371
|
+
return '';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// hasFileWithSubstring lê path e retorna true se seu conteúdo contém sub. Retorna false
|
|
375
|
+
// silenciosamente se o arquivo não existir ou não puder ser lido (best-effort).
|
|
376
|
+
function hasFileWithSubstring(p, sub) {
|
|
377
|
+
let content;
|
|
378
|
+
try { content = fs.readFileSync(p, 'utf8'); } catch { return false; }
|
|
379
|
+
return content.includes(sub);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// hasGoTestFile percorre rootDir recursivamente procurando qualquer arquivo *_test.go.
|
|
383
|
+
function hasGoTestFile(rootDir) {
|
|
384
|
+
let found = false;
|
|
385
|
+
function walk(d) {
|
|
386
|
+
if (found) return;
|
|
387
|
+
let entries;
|
|
388
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
389
|
+
for (const e of entries) {
|
|
390
|
+
if (found) return;
|
|
391
|
+
if (e.isDirectory()) {
|
|
392
|
+
walk(path.join(d, e.name));
|
|
393
|
+
} else if (e.name.endsWith('_test.go')) {
|
|
394
|
+
found = true;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
walk(rootDir);
|
|
400
|
+
return found;
|
|
401
|
+
}
|
|
402
|
+
|
|
341
403
|
function listSubDirs(dir) {
|
|
342
404
|
try {
|
|
343
405
|
return fs.readdirSync(dir).filter(f => {
|
|
@@ -412,6 +474,12 @@ cmd.action((opts) => {
|
|
|
412
474
|
console.log('⚠ No CI system detected');
|
|
413
475
|
}
|
|
414
476
|
|
|
477
|
+
// suggested test framework — printed only, never written to trackfw.yaml automatically
|
|
478
|
+
// (agent_conventions must always be declared by the team).
|
|
479
|
+
if (r.suggestedTestFramework) {
|
|
480
|
+
console.log(`Suggested test framework: ${r.suggestedTestFramework} (add to trackfw.yaml as agent_conventions: if correct)`);
|
|
481
|
+
}
|
|
482
|
+
|
|
415
483
|
console.log(`\nGovernance Score: ${r.governanceScore}/100`);
|
|
416
484
|
|
|
417
485
|
if (opts.init) {
|
|
@@ -447,6 +515,12 @@ cmd.action((opts) => {
|
|
|
447
515
|
} catch (e) {
|
|
448
516
|
console.warn(`⚠ credential guard script: ${e.message}`);
|
|
449
517
|
}
|
|
518
|
+
try {
|
|
519
|
+
const { generateGitBranchGuardScript } = require('../generators/hooks');
|
|
520
|
+
generateGitBranchGuardScript(cwd);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
console.warn(`⚠ git branch guard script: ${e.message}`);
|
|
523
|
+
}
|
|
450
524
|
try {
|
|
451
525
|
const { injectHooksDetected } = require('../generators/hooks');
|
|
452
526
|
injectHooksDetected(cwd);
|
|
@@ -500,3 +574,4 @@ module.exports.generateBootstrapLog = generateBootstrapLog;
|
|
|
500
574
|
module.exports.writeValidateScript = writeValidateScript;
|
|
501
575
|
module.exports.writeCIWorkflow = writeCIWorkflow;
|
|
502
576
|
module.exports.writeCIWorkflowForce = writeCIWorkflowForce;
|
|
577
|
+
module.exports.detectTestFramework = detectTestFramework;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander')
|
|
4
|
+
const { runDoctor, UNREGISTERED_WRITE, HAND_MODIFIED, UNKNOWN_CONTENT } = require('../integrations/doctor')
|
|
5
|
+
|
|
6
|
+
function printReport(findings) {
|
|
7
|
+
if (findings.length === 0) {
|
|
8
|
+
return 'trackfw doctor: no mismatches found -- disk matches the manifest for every catalog-managed artifact.'
|
|
9
|
+
}
|
|
10
|
+
const unregistered = findings.filter(finding => finding.finding === UNREGISTERED_WRITE).length
|
|
11
|
+
const handModified = findings.filter(finding => finding.finding === HAND_MODIFIED).length
|
|
12
|
+
const unknownContent = findings.filter(finding => finding.finding === UNKNOWN_CONTENT).length
|
|
13
|
+
const lines = [`trackfw doctor: ${findings.length} finding(s) -- ${unregistered} unregistered-write, ${handModified} hand-modified, ${unknownContent} unknown-content`, '']
|
|
14
|
+
for (const finding of findings) {
|
|
15
|
+
lines.push(`[${finding.finding}] ${finding.destination}`, ` remedy: ${finding.remedy}`, '')
|
|
16
|
+
}
|
|
17
|
+
return lines.join('\n').replace(/\n$/, '')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const cmd = new Command('doctor')
|
|
21
|
+
cmd.description('Detect artifacts on disk missing from the manifest, distinguishing hand-modified artifacts from unknown content')
|
|
22
|
+
cmd.option('--json', 'Emit findings as a JSON array instead of the text report')
|
|
23
|
+
cmd.action(async options => {
|
|
24
|
+
const findings = runDoctor({})
|
|
25
|
+
if (options.json) {
|
|
26
|
+
process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
process.stdout.write(`${printReport(findings)}\n`)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
module.exports = cmd
|
package/src/commands/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { Command } = require('commander')
|
|
4
4
|
const { version } = require('../../package.json')
|
|
5
|
+
const { formatUnknownCommandError } = require('../lib/unknown-command')
|
|
5
6
|
|
|
6
7
|
function createProgram() {
|
|
7
8
|
const program = new Command()
|
|
@@ -9,6 +10,25 @@ function createProgram() {
|
|
|
9
10
|
.name('trackfw')
|
|
10
11
|
.description('trackfw — governed software delivery framework\nADR → REQ → ROADMAP → kanban')
|
|
11
12
|
.version(`trackfw ${version}`)
|
|
13
|
+
// enablePositionalOptions: garante que uma flag de subcomando (ex.: o
|
|
14
|
+
// "changelog --version <x.y.z>") não seja capturada pela flag global
|
|
15
|
+
// "--version" do root — sem isto, commander casa "--version" contra o
|
|
16
|
+
// primeiro registro conhecido em toda a árvore de comandos.
|
|
17
|
+
program.enablePositionalOptions()
|
|
18
|
+
|
|
19
|
+
// Comando desconhecido — mensagem canônica compartilhada pelos 3 CLIs
|
|
20
|
+
// (ADR-2026-08-15-remocao-do-subsistema-de-plugins-em-vez-de-gate-de-binario-
|
|
21
|
+
// de-terceiro.md D3). Registrar um listener 'command:*' intercepta ANTES do
|
|
22
|
+
// commander chamar seu próprio Command.unknownCommand() — evita reformatar
|
|
23
|
+
// uma mensagem já impressa por ele (o padrão que causava dupla-impressão no
|
|
24
|
+
// Go, ver internal/commands/root.go) e evita depender do texto/threshold
|
|
25
|
+
// próprio de commander.suggestSimilar, que diverge de cobra/argparse.
|
|
26
|
+
program.on('command:*', (operands) => {
|
|
27
|
+
const typed = operands[0]
|
|
28
|
+
const candidates = program.commands.map((cmd) => cmd.name())
|
|
29
|
+
process.stderr.write(formatUnknownCommandError(typed, candidates, program.name()) + '\n')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
})
|
|
12
32
|
|
|
13
33
|
program.addCommand(require('./init'))
|
|
14
34
|
program.addCommand(require('./adr'))
|
|
@@ -16,8 +36,8 @@ function createProgram() {
|
|
|
16
36
|
program.addCommand(require('./roadmap'))
|
|
17
37
|
program.addCommand(require('./validate'))
|
|
18
38
|
program.addCommand(require('./status'))
|
|
39
|
+
program.addCommand(require('./changelog'))
|
|
19
40
|
program.addCommand(require('./log'))
|
|
20
|
-
program.addCommand(require('./plugins'))
|
|
21
41
|
program.addCommand(require('./discover'))
|
|
22
42
|
program.addCommand(require('./update'))
|
|
23
43
|
program.addCommand(require('./metrics'))
|
|
@@ -33,13 +53,12 @@ function createProgram() {
|
|
|
33
53
|
program.addCommand(require('./ship'))
|
|
34
54
|
program.addCommand(require('./barrier'))
|
|
35
55
|
program.addCommand(require('./branch'))
|
|
56
|
+
program.addCommand(require('./commit'))
|
|
57
|
+
program.addCommand(require('./doctor'))
|
|
36
58
|
|
|
37
59
|
const { createServeCommand } = require('./serve')
|
|
38
60
|
program.addCommand(createServeCommand())
|
|
39
61
|
|
|
40
|
-
// plugin dispatch — comandos desconhecidos tentam executar plugin
|
|
41
|
-
program.hook('preSubcommand', () => {})
|
|
42
|
-
|
|
43
62
|
return program
|
|
44
63
|
}
|
|
45
64
|
|
|
@@ -6,10 +6,26 @@ const { catalog, execute, parseSurfaces, buildPlans } = require('../integrations
|
|
|
6
6
|
const identityStore = require('../identity')
|
|
7
7
|
const identityWizard = require('./identity-wizard')
|
|
8
8
|
const { t } = require('../i18n')
|
|
9
|
+
const { createThirdPartyCommand } = require('./thirdparty')
|
|
9
10
|
|
|
10
11
|
const csv = value => String(value).split(',').map(entry => entry.trim()).filter(Boolean)
|
|
11
12
|
const collect = (value, previous) => previous.concat(value)
|
|
12
13
|
|
|
14
|
+
// forceHelp returns the --force help text for a mutation subcommand. The
|
|
15
|
+
// three operations grant --force different powers, and a single shared
|
|
16
|
+
// string previously overstated update/uninstall's reach while never
|
|
17
|
+
// mentioning install's ability to adopt unmanaged bytes — that ambiguity is
|
|
18
|
+
// what sent a user straight into the "Unmanaged artifact does not match a
|
|
19
|
+
// trackfw template" error on `update --force` (see unmanagedArtifactError in
|
|
20
|
+
// src/integrations/manager.js for the matching remediation). Mirrors
|
|
21
|
+
// internal/commands/integrations_flags.go:forceHelp and
|
|
22
|
+
// pypi/trackfw/integrations/command.py.
|
|
23
|
+
function forceHelp(operation) {
|
|
24
|
+
if (operation === 'install') return 'Replace a modified managed artifact, or adopt/overwrite an unmanaged file already on disk'
|
|
25
|
+
if (operation === 'uninstall') return 'Remove a modified managed artifact'
|
|
26
|
+
return "Replace a modified managed artifact; never adopts unmanaged bytes — use 'install --force' for that"
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
function human(result) {
|
|
14
30
|
const lines = [`Available ${result.kind} (catalog ${result.catalog_version}):`]
|
|
15
31
|
for (const item of result.items) lines.push(` ${item.id.padEnd(14)} ${item.name} — ${item.description}`)
|
|
@@ -102,6 +118,12 @@ async function promptAmbiguousSurfaces(kind, options, prompts = require('@inquir
|
|
|
102
118
|
|
|
103
119
|
function createLifecycleCommand(kind) {
|
|
104
120
|
const root = new Command(kind).description(`Manage trackfw ${kind}`)
|
|
121
|
+
// third-party fetch/install (D1): reachable from both `trackfw agents
|
|
122
|
+
// third-party` and `trackfw skills third-party`, under the same
|
|
123
|
+
// two-phase quarantine gate. Mirrors
|
|
124
|
+
// internal/commands/integrations_flags.go registering `third-party` in
|
|
125
|
+
// newIntegrationsLifecycleCmd.
|
|
126
|
+
root.addCommand(createThirdPartyCommand(kind))
|
|
105
127
|
for (const operation of ['list', 'install', 'uninstall', 'update']) {
|
|
106
128
|
const mutation = operation !== 'list'
|
|
107
129
|
const command = new Command(operation)
|
|
@@ -110,7 +132,7 @@ function createLifecycleCommand(kind) {
|
|
|
110
132
|
.option('--scope <scope>', 'Installation scope: project or global (default: global; asks interactively)')
|
|
111
133
|
.option('--surface <target=surface>', 'Surface selection (repeatable)', collect, [])
|
|
112
134
|
.option('--json', 'Print deterministic JSON')
|
|
113
|
-
.option('--force',
|
|
135
|
+
.option('--force', forceHelp(operation))
|
|
114
136
|
|
|
115
137
|
// Flags de identidade são exclusivas de agents (ADR D5): skills não têm
|
|
116
138
|
// identidade, e createLifecycleCommand é compartilhado entre `agents` e
|
package/src/commands/serve.js
CHANGED
|
@@ -13,6 +13,48 @@ const { handleAttention } = require('../serve/api_attention')
|
|
|
13
13
|
|
|
14
14
|
const STATIC_DIR = path.join(__dirname, '..', 'serve', 'static')
|
|
15
15
|
|
|
16
|
+
// Aviso pinado, byte-idêntico entre os 3 runtimes (Go, Node.js, Python) — ver
|
|
17
|
+
// docs/cli-parity.md "`trackfw serve` — endereço de escuta, `--host` e
|
|
18
|
+
// aviso de exposição". Emitido quando --host resolve para uma interface
|
|
19
|
+
// diferente de loopback.
|
|
20
|
+
function exposureWarning(host, port) {
|
|
21
|
+
return `WARNING: trackfw serve is binding to ${host}:${port} — the governance chain (ADRs, REQs, roadmaps) will be readable without authentication by any device that can reach it.`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Texto de --help pinado, byte-idêntico entre os 3 runtimes.
|
|
25
|
+
const SERVE_HOST_FLAG_HELP = 'Host to bind to (loopback only by default; use 0.0.0.0 to expose on the network)'
|
|
26
|
+
|
|
27
|
+
// Espelha internal/serve/serve.go IsLoopbackHost — 'localhost' ou IP loopback
|
|
28
|
+
// (IPv4 127.0.0.0/8 inteiro, ou IPv6 ::1), igual a net.IP.IsLoopback() do Go
|
|
29
|
+
// e ipaddress.ip_address(...).is_loopback do Python.
|
|
30
|
+
function isLoopbackHost(host) {
|
|
31
|
+
const net = require('net')
|
|
32
|
+
if (host === 'localhost') return true
|
|
33
|
+
if (net.isIPv4(host)) {
|
|
34
|
+
return host.split('.')[0] === '127'
|
|
35
|
+
}
|
|
36
|
+
if (net.isIPv6(host)) {
|
|
37
|
+
return host === '::1' || host === '0:0:0:0:0:0:0:1'
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Espelha internal/serve/serve.go DisplayURL — URL a imprimir e a abrir no
|
|
43
|
+
// browser. 'localhost' é mantido só para 'localhost' ou IPv4 loopback
|
|
44
|
+
// (127.0.0.0/8), para não mudar a saída do caso comum; hosts IPv6 usam
|
|
45
|
+
// colchetes; qualquer outro host é impresso como está.
|
|
46
|
+
function displayUrl(host, port) {
|
|
47
|
+
const net = require('net')
|
|
48
|
+
if (host === 'localhost') return `http://localhost:${port}`
|
|
49
|
+
if (net.isIPv4(host)) {
|
|
50
|
+
return host.split('.')[0] === '127' ? `http://localhost:${port}` : `http://${host}:${port}`
|
|
51
|
+
}
|
|
52
|
+
if (net.isIPv6(host)) {
|
|
53
|
+
return `http://[${host}]:${port}`
|
|
54
|
+
}
|
|
55
|
+
return `http://${host}:${port}`
|
|
56
|
+
}
|
|
57
|
+
|
|
16
58
|
// Mapa de extensão → Content-Type
|
|
17
59
|
const MIME = {
|
|
18
60
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -141,19 +183,25 @@ function createServeCommand() {
|
|
|
141
183
|
cmd
|
|
142
184
|
.description('Inicia o servidor HTTP do trackfw dashboard (kanban + chain + metrics)')
|
|
143
185
|
.option('--port <port>', 'Porta do servidor', '8080')
|
|
186
|
+
.option('--host <host>', SERVE_HOST_FLAG_HELP, '127.0.0.1')
|
|
144
187
|
.option('--no-open', 'Não abrir o browser automaticamente')
|
|
145
188
|
.action((opts) => {
|
|
146
189
|
const cfg = config.load()
|
|
147
190
|
const port = parseInt(opts.port, 10) || 8080
|
|
191
|
+
const host = opts.host
|
|
148
192
|
|
|
149
193
|
const server = createServer(cfg, port)
|
|
150
194
|
|
|
151
|
-
|
|
152
|
-
console.
|
|
195
|
+
if (!isLoopbackHost(host)) {
|
|
196
|
+
console.error(exposureWarning(host, port))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
server.listen(port, host, () => {
|
|
200
|
+
const url = displayUrl(host, port)
|
|
201
|
+
console.log(`trackfw serve: ${url}`)
|
|
153
202
|
|
|
154
203
|
if (opts.open !== false) {
|
|
155
204
|
// Tentar abrir o browser
|
|
156
|
-
const url = `http://localhost:${port}`
|
|
157
205
|
const { exec } = require('child_process')
|
|
158
206
|
const platform = process.platform
|
|
159
207
|
let openCmd
|
|
@@ -187,4 +235,4 @@ function createServeCommand() {
|
|
|
187
235
|
return cmd
|
|
188
236
|
}
|
|
189
237
|
|
|
190
|
-
module.exports = { createServeCommand, createServer }
|
|
238
|
+
module.exports = { createServeCommand, createServer, isLoopbackHost, displayUrl }
|
package/src/commands/ship.js
CHANGED
|
@@ -9,9 +9,10 @@ function createShipCommand() {
|
|
|
9
9
|
cmd
|
|
10
10
|
.description(
|
|
11
11
|
'trackfw ship runs a governed delivery sequence:\n\n' +
|
|
12
|
-
' 1. Validates branch name — must match feat|fix|refactor/<slug>\n' +
|
|
13
|
-
' 2. Validates governance — REQ + roadmap in wip/ must exist\n' +
|
|
14
|
-
' (hard gate: not affected by lenient mode or per-rule severity)\n' +
|
|
12
|
+
' 1. Validates branch name — must match feat|fix|refactor|chore|docs/<slug>\n' +
|
|
13
|
+
' 2. Validates governance — REQ + roadmap in wip/ must exist for feat/fix/refactor branches\n' +
|
|
14
|
+
' (hard gate: not affected by lenient mode or per-rule severity); chore/docs branches skip\n' +
|
|
15
|
+
" this check, mirroring 'trackfw commit'\n" +
|
|
15
16
|
' 3. Detects pending squash-merges in other branches (advisory only)\n' +
|
|
16
17
|
' 4. Reviews what is staged (git status --short + git diff --cached --stat)\n' +
|
|
17
18
|
' 5. Commits with Conventional Commits format (-m is required)\n' +
|