trackfw 7.1.0 → 7.3.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/commands/audit-surface.js +520 -0
- package/src/commands/barrier.js +107 -8
- package/src/commands/commit.js +7 -3
- package/src/commands/doctor.js +17 -3
- package/src/commands/index.js +3 -0
- package/src/commands/integrations.js +80 -1
- package/src/commands/push.js +51 -0
- package/src/commands/release.js +47 -0
- package/src/commands/ship.js +10 -2
- package/src/commands/thirdparty.js +7 -2
- package/src/commands/update-harness.js +4 -1
- package/src/commands/update.js +21 -2
- package/src/config/index.js +96 -2
- package/src/generators/hooks.js +194 -13
- package/src/generators/init.js +84 -16
- package/src/generators/roadmap.js +53 -2
- package/src/integrations/assets/agents/architect.md +17 -3
- package/src/integrations/assets/agents/security.md +3 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/index.js +1 -1
- package/src/integrations/render.js +116 -2
- package/src/integrations/scaffold_doctor.js +366 -0
- package/src/lib/update-engine.js +13 -1
- package/src/push/runner.js +223 -0
- package/src/release/runner.js +474 -0
- package/src/ship/runner.js +177 -21
- package/src/validator/index.js +270 -13
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scaffold_doctor.js — scaffold artifact coverage for `trackfw doctor`
|
|
5
|
+
* (ADR-2026-08-27-doctor-cobre-artefatos-de-scaffold-por-comparacao-com-o-template-
|
|
6
|
+
* com-propriedade-dada-pelo-caminho.md).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors internal/generators/scaffold_doctor.go (Go, canonical source of truth).
|
|
9
|
+
* Same design decisions apply — see that file's doc comment for the full rationale.
|
|
10
|
+
*
|
|
11
|
+
* Key decisions:
|
|
12
|
+
* - Property by path, not manifest (AC3): scaffold artifacts are identified by
|
|
13
|
+
* well-known namespace paths. No manifest entry is written or read.
|
|
14
|
+
* - Sibling classifier (AC15): scaffold artifacts are never in the manifest, so
|
|
15
|
+
* routing them through classifyDoctor would produce wrong remedies. The finding
|
|
16
|
+
* kinds SCAFFOLD_DIVERGENT / SCAFFOLD_MISSING are separate, with zero claim and
|
|
17
|
+
* a trackfw-update remedy.
|
|
18
|
+
* - Config-rendered templates (AC12): buildValidateScript(cfg) varies with
|
|
19
|
+
* cfg.backend/cfg.frontend — rendered from the project's own trackfw.yaml.
|
|
20
|
+
* - Eligibility for slash commands (AC14): only checked when
|
|
21
|
+
* .claude/commands/trackfw/ directory already exists.
|
|
22
|
+
* - Conditional artifacts (AC13): CI workflow only when cfg.ci declares it.
|
|
23
|
+
* - Neutral blame message (AC16): binary version stated; direction ambiguous.
|
|
24
|
+
* - Execute-bit checking (REQ-2026-08-28, AC2–AC5, AC10, AC11):
|
|
25
|
+
* The five scripts written with mode 0o755 are additionally checked for the
|
|
26
|
+
* owner-execute bit. The check uses (stat.mode & 0o100) !== 0 (not === 0o755)
|
|
27
|
+
* so umask-narrowed modes (0o750, 0o700) are accepted (AC10). Non-executable
|
|
28
|
+
* artifacts (slash commands, CI workflows) carry execBit=false and are never
|
|
29
|
+
* accused of a missing execute bit (AC4/AC11). Content divergence takes
|
|
30
|
+
* precedence over mode (at most one finding per artifact). Content correct +
|
|
31
|
+
* bit missing → SCAFFOLD_WRONG_MODE (AC3 distinct state). On Windows
|
|
32
|
+
* (process.platform === 'win32') the execute bit is not representable on NTFS,
|
|
33
|
+
* so the mode check is suppressed entirely — AC5.
|
|
34
|
+
*
|
|
35
|
+
* _platform is a module-level constant seeded from process.platform so that unit
|
|
36
|
+
* tests can override it via the exported _setPlatformForTest helper (AC5 testability).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const fs = require('fs')
|
|
40
|
+
const path = require('path')
|
|
41
|
+
const yaml = require('yaml')
|
|
42
|
+
|
|
43
|
+
const { SIGNAL_SCRIPT, CLEANUP_SCRIPT, CREDENTIAL_GUARD_SCRIPT, GIT_BRANCH_GUARD_SCRIPT } =
|
|
44
|
+
require('../generators/hooks')
|
|
45
|
+
const { CLAUDE_COMMANDS, buildValidateScript, buildGitHubActionsWorkflowContent, buildGitLabCIWorkflowContent } =
|
|
46
|
+
require('../generators/init')
|
|
47
|
+
|
|
48
|
+
// Finding kind constants — mirrors Go's DoctorScaffoldDivergent / DoctorScaffoldMissing /
|
|
49
|
+
// DoctorScaffoldWrongMode.
|
|
50
|
+
const SCAFFOLD_DIVERGENT = 'scaffold-divergent'
|
|
51
|
+
const SCAFFOLD_MISSING = 'scaffold-missing'
|
|
52
|
+
// SCAFFOLD_WRONG_MODE: content correct but owner-execute bit missing (AC3).
|
|
53
|
+
// Only emitted for artifacts with execBit=true; never on Windows (AC5).
|
|
54
|
+
const SCAFFOLD_WRONG_MODE = 'scaffold-wrong-mode'
|
|
55
|
+
|
|
56
|
+
// PYTHON_VALIDATE_SCRIPT_FORM is the byte-exact content Python's `trackfw init` and
|
|
57
|
+
// `trackfw update` (validate-script target) write to scripts/trackfw-validate.sh.
|
|
58
|
+
// Accepted by the set-membership check in checkValidateScriptArtifact so that a
|
|
59
|
+
// project initialized by the Python runtime does not produce a false-positive
|
|
60
|
+
// scaffold-divergent finding in the Node.js doctor.
|
|
61
|
+
//
|
|
62
|
+
// Scope of the exception: ONLY scripts/trackfw-validate.sh uses set-membership.
|
|
63
|
+
// All other scaffold artifacts use single-template equality.
|
|
64
|
+
// See docs/cli-parity.md, "validate.sh — pertencimento a conjunto (set-membership, escopado)".
|
|
65
|
+
const PYTHON_VALIDATE_SCRIPT_FORM = '#!/usr/bin/env bash\nset -euo pipefail\ntrackfw validate\n'
|
|
66
|
+
|
|
67
|
+
// Paths used by the scaffold doctor (mirrors Go's exported constants).
|
|
68
|
+
const CLAUDE_COMMANDS_DIR_PATH = '.claude/commands/trackfw'
|
|
69
|
+
const GITHUB_ACTIONS_WORKFLOW_PATH = '.github/workflows/trackfw-gate.yml'
|
|
70
|
+
const GITLAB_CI_WORKFLOW_PATH = '.gitlab-ci-trackfw.yml'
|
|
71
|
+
|
|
72
|
+
// _platform is seeded from process.platform at module load time.
|
|
73
|
+
// Tests override it via _setPlatformForTest to exercise the Windows guard (AC5).
|
|
74
|
+
let _platform = process.platform
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* _setPlatformForTest overrides the platform string used by mode checks.
|
|
78
|
+
* ONLY for use in unit tests. Returns a restore function.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} plat - e.g. 'win32' or 'darwin'
|
|
81
|
+
* @returns {function} call to restore the original value
|
|
82
|
+
*/
|
|
83
|
+
function _setPlatformForTest(plat) {
|
|
84
|
+
const prev = _platform
|
|
85
|
+
_platform = plat
|
|
86
|
+
return () => { _platform = prev }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* execBitPresent returns true when the file at absPath has the owner-execute bit set
|
|
91
|
+
* (stat.mode & 0o100 !== 0). Returns false on any stat error.
|
|
92
|
+
*
|
|
93
|
+
* Uses bit mask rather than equality to 0o755 so that umask-narrowed modes like
|
|
94
|
+
* 0o750 or 0o700 are also accepted — AC10.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} absPath - absolute path to the file
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function execBitPresent(absPath) {
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.statSync(absPath)
|
|
102
|
+
return (stat.mode & 0o100) !== 0
|
|
103
|
+
} catch (_) {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* scaffoldRemedy returns a ready-to-copy remedy command for a scaffold finding.
|
|
110
|
+
* The message is neutral about blame direction (AC16).
|
|
111
|
+
*/
|
|
112
|
+
function scaffoldRemedy(action, relPath) {
|
|
113
|
+
// Lazy-require version to avoid a circular dependency at module load time.
|
|
114
|
+
let ver = 'unknown'
|
|
115
|
+
try {
|
|
116
|
+
const pkg = require('../../package.json')
|
|
117
|
+
ver = pkg.version || 'unknown'
|
|
118
|
+
} catch (_) {}
|
|
119
|
+
return `trackfw update # ${action} ${relPath}: content differs from the template trackfw v${ver} generates; if this project was initialized with a newer binary, update the binary instead`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* scaffoldWrongModeRemedy returns a remedy command for the scaffold-wrong-mode finding.
|
|
124
|
+
* Names the missing execute bit explicitly to distinguish it from content divergence (AC3).
|
|
125
|
+
* The message is runtime-neutral so the parity check can diff Go, Node, and Python outputs
|
|
126
|
+
* byte-for-byte on this finding kind.
|
|
127
|
+
*/
|
|
128
|
+
function scaffoldWrongModeRemedy(relPath) {
|
|
129
|
+
return `trackfw update # restore execute bit on ${relPath}: content is correct but the owner-execute bit is missing (mode 0755 required); trackfw update now restores the mode unconditionally on existing files`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* loadProjectConfig reads trackfw.yaml from projectRoot and returns a cfg object
|
|
134
|
+
* with the same keys that buildValidateScript / buildCIWorkflowContent expect.
|
|
135
|
+
* Missing keys default to undefined/null — identical to Go's loadUpdateConfig().
|
|
136
|
+
*/
|
|
137
|
+
function loadProjectConfig(projectRoot) {
|
|
138
|
+
try {
|
|
139
|
+
const raw = fs.readFileSync(path.join(projectRoot, 'trackfw.yaml'), 'utf8')
|
|
140
|
+
const parsed = yaml.parse(raw) || {}
|
|
141
|
+
return {
|
|
142
|
+
backend: parsed.backend || null,
|
|
143
|
+
frontend: parsed.frontend || null,
|
|
144
|
+
pkgManager: parsed.pkg_manager || null,
|
|
145
|
+
ci: parsed.ci || null,
|
|
146
|
+
}
|
|
147
|
+
} catch (_) {
|
|
148
|
+
return { backend: null, frontend: null, pkgManager: null, ci: null }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* checkValidateScriptArtifact checks scripts/trackfw-validate.sh using set-membership:
|
|
154
|
+
* accepted if the file content matches EITHER Go/Node's cfg-rendered form OR Python's
|
|
155
|
+
* fixed form (PYTHON_VALIDATE_SCRIPT_FORM). A file matching NO known form is accused.
|
|
156
|
+
* All other scaffold artifacts use checkScaffoldArtifact (single-template equality).
|
|
157
|
+
*
|
|
158
|
+
* After content membership passes, the execute bit is checked unless on Windows (AC5).
|
|
159
|
+
* Content divergence always takes precedence over mode (at most one finding per artifact).
|
|
160
|
+
*
|
|
161
|
+
* @param {string} absPath - absolute path to the file
|
|
162
|
+
* @param {string} relPath - relative path (used as destination in findings)
|
|
163
|
+
* @param {object} cfg - project config from trackfw.yaml
|
|
164
|
+
* @returns {object|null} finding or null
|
|
165
|
+
*/
|
|
166
|
+
function checkValidateScriptArtifact(absPath, relPath, cfg) {
|
|
167
|
+
let actual
|
|
168
|
+
try {
|
|
169
|
+
actual = fs.readFileSync(absPath, 'utf8')
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err.code === 'ENOENT') {
|
|
172
|
+
return {
|
|
173
|
+
finding: SCAFFOLD_MISSING,
|
|
174
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
175
|
+
destination: relPath,
|
|
176
|
+
remedy: scaffoldRemedy('restore', relPath),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
181
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
182
|
+
destination: relPath,
|
|
183
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const goNodeForm = buildValidateScript(cfg)
|
|
187
|
+
if (actual !== goNodeForm && actual !== PYTHON_VALIDATE_SCRIPT_FORM) {
|
|
188
|
+
// Content diverges — scaffold-divergent takes precedence over any mode issue.
|
|
189
|
+
return {
|
|
190
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
191
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
192
|
+
destination: relPath,
|
|
193
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Content accepted. Check the execute bit (AC2/AC3).
|
|
197
|
+
// Suppressed on Windows where the bit is not representable (AC5).
|
|
198
|
+
if (_platform !== 'win32' && !execBitPresent(absPath)) {
|
|
199
|
+
return {
|
|
200
|
+
finding: SCAFFOLD_WRONG_MODE,
|
|
201
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
202
|
+
destination: relPath,
|
|
203
|
+
remedy: scaffoldWrongModeRemedy(relPath),
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* checkScaffoldArtifact compares the on-disk content at absPath against expected.
|
|
211
|
+
* Returns a finding if the file is divergent or (when reportMissing=true) absent.
|
|
212
|
+
* relPath is used as destination in the finding.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} absPath - absolute path to the file
|
|
215
|
+
* @param {string} relPath - relative path (destination in findings)
|
|
216
|
+
* @param {string} expected - expected file content
|
|
217
|
+
* @param {boolean} reportMissing - if true, absence is a finding
|
|
218
|
+
* @param {boolean} execBit - if true, check the owner-execute bit after content passes
|
|
219
|
+
* true → artifact was written with 0o755; bit absence → SCAFFOLD_WRONG_MODE (AC2/AC3)
|
|
220
|
+
* false → artifact is 0o644 (slash commands, CI workflows); never mode-checked (AC4/AC11)
|
|
221
|
+
* @returns {object|null} finding or null
|
|
222
|
+
*/
|
|
223
|
+
function checkScaffoldArtifact(absPath, relPath, expected, reportMissing, execBit) {
|
|
224
|
+
let actual
|
|
225
|
+
try {
|
|
226
|
+
actual = fs.readFileSync(absPath, 'utf8')
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if (err.code === 'ENOENT') {
|
|
229
|
+
if (!reportMissing) return null
|
|
230
|
+
return {
|
|
231
|
+
finding: SCAFFOLD_MISSING,
|
|
232
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
233
|
+
destination: relPath,
|
|
234
|
+
remedy: scaffoldRemedy('restore', relPath),
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Unreadable artifact: report as divergent so the user is informed.
|
|
238
|
+
return {
|
|
239
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
240
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
241
|
+
destination: relPath,
|
|
242
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (actual !== expected) {
|
|
246
|
+
// Content diverges — takes precedence over any mode issue (at most one finding
|
|
247
|
+
// per artifact; update fixes both content and mode anyway).
|
|
248
|
+
return {
|
|
249
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
250
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
251
|
+
destination: relPath,
|
|
252
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Content matches. Check the execute bit when required (AC2/AC3).
|
|
256
|
+
// Suppressed on Windows where the bit is not representable (AC5).
|
|
257
|
+
if (execBit && _platform !== 'win32' && !execBitPresent(absPath)) {
|
|
258
|
+
return {
|
|
259
|
+
finding: SCAFFOLD_WRONG_MODE,
|
|
260
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
261
|
+
destination: relPath,
|
|
262
|
+
remedy: scaffoldWrongModeRemedy(relPath),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* runScaffoldDoctor compares scaffold artifacts on disk against the templates the
|
|
270
|
+
* currently installed binary would generate (given the project's own trackfw.yaml),
|
|
271
|
+
* and returns findings for any artifact that is divergent or missing.
|
|
272
|
+
*
|
|
273
|
+
* @param {string} projectRoot - absolute path to the project root
|
|
274
|
+
* @returns {Array} findings
|
|
275
|
+
*/
|
|
276
|
+
function runScaffoldDoctor(projectRoot) {
|
|
277
|
+
// Eligibility: trackfw.yaml must exist.
|
|
278
|
+
try {
|
|
279
|
+
fs.statSync(path.join(projectRoot, 'trackfw.yaml'))
|
|
280
|
+
} catch (_) {
|
|
281
|
+
return []
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const cfg = loadProjectConfig(projectRoot)
|
|
285
|
+
const findings = []
|
|
286
|
+
|
|
287
|
+
// --- Scripts (always in scope when trackfw.yaml is present) ---
|
|
288
|
+
//
|
|
289
|
+
// scripts/trackfw-validate.sh uses set-membership (Go/Node form OR Python form).
|
|
290
|
+
// The four remaining scripts use single-template equality via checkScaffoldArtifact.
|
|
291
|
+
// All five have execBit=true: the generator writes them with mode 0o755 (AC11).
|
|
292
|
+
const validateF = checkValidateScriptArtifact(
|
|
293
|
+
path.join(projectRoot, 'scripts/trackfw-validate.sh'),
|
|
294
|
+
'scripts/trackfw-validate.sh',
|
|
295
|
+
cfg,
|
|
296
|
+
)
|
|
297
|
+
if (validateF) findings.push(validateF)
|
|
298
|
+
|
|
299
|
+
const staticScripts = [
|
|
300
|
+
{ relPath: 'scripts/trackfw-attention-signal.sh', expected: SIGNAL_SCRIPT, execBit: true },
|
|
301
|
+
{ relPath: 'scripts/trackfw-attention-cleanup.sh', expected: CLEANUP_SCRIPT, execBit: true },
|
|
302
|
+
{ relPath: 'scripts/trackfw-credential-guard.sh', expected: CREDENTIAL_GUARD_SCRIPT, execBit: true },
|
|
303
|
+
{ relPath: 'scripts/trackfw-git-branch-guard.sh', expected: GIT_BRANCH_GUARD_SCRIPT, execBit: true },
|
|
304
|
+
]
|
|
305
|
+
for (const { relPath, expected, execBit } of staticScripts) {
|
|
306
|
+
const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, expected, true, execBit)
|
|
307
|
+
if (f) findings.push(f)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --- Slash commands (AC14: only when the directory already exists) ---
|
|
311
|
+
//
|
|
312
|
+
// Slash commands are markdown files written with mode 0644 — execBit=false (AC11).
|
|
313
|
+
const claudeDir = path.join(projectRoot, CLAUDE_COMMANDS_DIR_PATH)
|
|
314
|
+
let claudeDirExists = false
|
|
315
|
+
try {
|
|
316
|
+
fs.statSync(claudeDir)
|
|
317
|
+
claudeDirExists = true
|
|
318
|
+
} catch (_) {}
|
|
319
|
+
if (claudeDirExists) {
|
|
320
|
+
for (const [filename, content] of Object.entries(CLAUDE_COMMANDS)) {
|
|
321
|
+
const relPath = `${CLAUDE_COMMANDS_DIR_PATH}/${filename}`
|
|
322
|
+
const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, content, true, false)
|
|
323
|
+
if (f) findings.push(f)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- CI workflow (AC13: conditional on ci: in trackfw.yaml) ---
|
|
328
|
+
//
|
|
329
|
+
// CI workflow YAML files are written with mode 0644 — execBit=false (AC11).
|
|
330
|
+
if (cfg.ci === 'github-actions') {
|
|
331
|
+
const relPath = GITHUB_ACTIONS_WORKFLOW_PATH
|
|
332
|
+
const f = checkScaffoldArtifact(
|
|
333
|
+
path.join(projectRoot, relPath),
|
|
334
|
+
relPath,
|
|
335
|
+
buildGitHubActionsWorkflowContent(cfg),
|
|
336
|
+
true,
|
|
337
|
+
false,
|
|
338
|
+
)
|
|
339
|
+
if (f) findings.push(f)
|
|
340
|
+
} else if (cfg.ci === 'gitlab-ci') {
|
|
341
|
+
const relPath = GITLAB_CI_WORKFLOW_PATH
|
|
342
|
+
const f = checkScaffoldArtifact(
|
|
343
|
+
path.join(projectRoot, relPath),
|
|
344
|
+
relPath,
|
|
345
|
+
buildGitLabCIWorkflowContent(cfg),
|
|
346
|
+
true,
|
|
347
|
+
false,
|
|
348
|
+
)
|
|
349
|
+
if (f) findings.push(f)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Deterministic output (AC7): sort by destination.
|
|
353
|
+
findings.sort((a, b) => (a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0))
|
|
354
|
+
|
|
355
|
+
return findings
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
runScaffoldDoctor,
|
|
360
|
+
checkValidateScriptArtifact,
|
|
361
|
+
SCAFFOLD_DIVERGENT,
|
|
362
|
+
SCAFFOLD_MISSING,
|
|
363
|
+
SCAFFOLD_WRONG_MODE,
|
|
364
|
+
PYTHON_VALIDATE_SCRIPT_FORM,
|
|
365
|
+
_setPlatformForTest,
|
|
366
|
+
}
|
package/src/lib/update-engine.js
CHANGED
|
@@ -94,7 +94,18 @@ function silenceConsole(fn) {
|
|
|
94
94
|
* - `missing` (nothing present under any of `relPaths`) never triggers
|
|
95
95
|
* `apply` unless `installMissing` is true.
|
|
96
96
|
*/
|
|
97
|
-
|
|
97
|
+
// runFileTarget — computes updated/skipped/missing/failed for a target whose
|
|
98
|
+
// only observable effect is writing under a fixed set of paths (files or
|
|
99
|
+
// directories) relative to `root`, by diffing content hashes before/after
|
|
100
|
+
// invoking `apply(root)`.
|
|
101
|
+
//
|
|
102
|
+
// `seeds` (optional) — extra paths copied into the dry-run sandbox but NOT
|
|
103
|
+
// included in the hash comparison. Used for:
|
|
104
|
+
// - trackfw.yaml: agent-rules reads it for agent_conventions (Gap E)
|
|
105
|
+
// - detection signals for agent-hooks: .windsurfrules, .github/copilot-
|
|
106
|
+
// instructions.md, etc. — InjectHooksDetected checks these to decide
|
|
107
|
+
// which hooks to write; absent → hooks silently omitted (Gap C)
|
|
108
|
+
function runFileTarget({ id, path: displayPath, root, relPaths, seeds = [], apply, dryRun, installMissing }) {
|
|
98
109
|
const before = relPaths.map(rel => hashPath(path.join(root, rel)))
|
|
99
110
|
const allMissingBefore = before.every(h => h === null)
|
|
100
111
|
|
|
@@ -108,6 +119,7 @@ function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, i
|
|
|
108
119
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trackfw-update-'))
|
|
109
120
|
try {
|
|
110
121
|
for (const rel of relPaths) copyPath(path.join(root, rel), path.join(tmp, rel))
|
|
122
|
+
for (const rel of seeds) copyPath(path.join(root, rel), path.join(tmp, rel))
|
|
111
123
|
silenceConsole(() => apply(tmp))
|
|
112
124
|
after = relPaths.map(rel => hashPath(path.join(tmp, rel)))
|
|
113
125
|
} finally {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* push/runner.js — Core implementation of `trackfw push`.
|
|
5
|
+
*
|
|
6
|
+
* Empurra commits já criados, sem commitar e sem abrir PR/MR.
|
|
7
|
+
* Reusa os helpers de npm/src/ship/runner.js; nunca reimplementa a lógica de governança,
|
|
8
|
+
* detecção de squash-merge ou construção de push args.
|
|
9
|
+
*
|
|
10
|
+
* See ADR-2026-08-22-comandos-de-entrega-separados-push-proprio-e-ship-como-composicao.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
isShipBranch,
|
|
15
|
+
isGatedShipBranch,
|
|
16
|
+
isGitWriteCmd,
|
|
17
|
+
checkShipGovernance,
|
|
18
|
+
detectPendingSquashMerges,
|
|
19
|
+
defaultCheckPROpen,
|
|
20
|
+
buildPushArgs,
|
|
21
|
+
defaultExecGit,
|
|
22
|
+
} = require('../ship/runner')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* defaultCheckGovernance delegates to checkShipGovernance — the same hard gate used by
|
|
26
|
+
* `trackfw ship` and `trackfw commit`.
|
|
27
|
+
* @returns {string[]}
|
|
28
|
+
*/
|
|
29
|
+
function defaultCheckGovernance() {
|
|
30
|
+
return checkShipGovernance()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { resolve: forgeResolve } = require('../forge/resolve')
|
|
34
|
+
const { forgeAdapter } = require('../forge/adapter')
|
|
35
|
+
|
|
36
|
+
// push --force-with-lease refusal messages — "trackfw push" (not "ship") because this command
|
|
37
|
+
// closes the commit→push cycle without opening a PR. See
|
|
38
|
+
// ADR-2026-08-22-comandos-de-entrega-separados-push-proprio-e-ship-como-composicao.md.
|
|
39
|
+
// Defined here (not imported from ship/runner.js) to keep push's user-visible messages
|
|
40
|
+
// independent of ship's contract string.
|
|
41
|
+
const PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG =
|
|
42
|
+
'trackfw push --force-with-lease requires a forge CLI (gh, glab, or az) to confirm an open pull/merge request before rewriting remote history. No forge CLI is available for this repository — install and authenticate it, or push without --force-with-lease.'
|
|
43
|
+
|
|
44
|
+
function pushForceLeaseNoPROpenMsg(branch) {
|
|
45
|
+
return `trackfw push --force-with-lease refuses to run: branch "${branch}" has no open pull/merge request. Open the PR/MR first (trackfw ship without --force-with-lease, or your forge's web UI), then retry.`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pushForceLeaseCannotVerifyMsg(branch, cliName, errMessage) {
|
|
49
|
+
return `trackfw push --force-with-lease could not verify whether branch "${branch}" has an open pull/merge request (${cliName} CLI error: ${errMessage}). Refusing rather than risking a force push without a verified PR — check your ${cliName} CLI authentication and retry.`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* runPush implements the push sequence: branch validation, governance, force-with-lease gate,
|
|
54
|
+
* squash-merge detection, and push. Never commits and never opens a PR/MR.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ dryRun?: boolean, forceWithLease?: boolean }} opts
|
|
57
|
+
* @param {{
|
|
58
|
+
* execGit?: function,
|
|
59
|
+
* checkGovernance?: function,
|
|
60
|
+
* writeErr?: function,
|
|
61
|
+
* configForge?: string,
|
|
62
|
+
* repoDir?: string,
|
|
63
|
+
* availFn?: function,
|
|
64
|
+
* checkPROpen?: function,
|
|
65
|
+
* }} deps
|
|
66
|
+
* @returns {number} exit code (0 = success)
|
|
67
|
+
*/
|
|
68
|
+
function runPush(opts, deps) {
|
|
69
|
+
opts = opts || {}
|
|
70
|
+
deps = deps || {}
|
|
71
|
+
|
|
72
|
+
const execGit = deps.execGit || defaultExecGit
|
|
73
|
+
const checkGovernanceFn = deps.checkGovernance || defaultCheckGovernance
|
|
74
|
+
// writeln is injectable (mirrors ship/runner.js) so tests can capture stdout without spawning
|
|
75
|
+
// a real process. Production callers omit it and fall back to process.stdout.
|
|
76
|
+
const writeln = deps.writeln || ((s) => process.stdout.write(s + '\n'))
|
|
77
|
+
// "Error: " prefix — mirrors Go exactly: push.go returns messages as errors, and
|
|
78
|
+
// root.go's ExecuteC path emits `cmd.ErrPrefix() + " " + err.Error() + "\n"` —
|
|
79
|
+
// cmd.ErrPrefix() is "Error:", so the combined line is "Error: <message>\n".
|
|
80
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(`Error: ${s}\n`))
|
|
81
|
+
|
|
82
|
+
// Inner wrapper: skips write commands in --dry-run mode.
|
|
83
|
+
function git(args) {
|
|
84
|
+
if (opts.dryRun && isGitWriteCmd(args)) {
|
|
85
|
+
writeln(`[dry-run] git ${args.join(' ')}`)
|
|
86
|
+
return { stdout: '', error: null }
|
|
87
|
+
}
|
|
88
|
+
return execGit(args)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
92
|
+
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
93
|
+
if (branchResult.error) {
|
|
94
|
+
writeErr(`could not determine current branch (are you in a git repo?): ${branchResult.error.message}`)
|
|
95
|
+
return 1
|
|
96
|
+
}
|
|
97
|
+
const branch = branchResult.stdout.trim()
|
|
98
|
+
|
|
99
|
+
// main/master is blocked unconditionally.
|
|
100
|
+
if (branch === 'main' || branch === 'master') {
|
|
101
|
+
writeErr(`trackfw push cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
102
|
+
return 1
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!isShipBranch(branch)) {
|
|
106
|
+
writeErr(
|
|
107
|
+
`branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
|
|
108
|
+
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
109
|
+
)
|
|
110
|
+
return 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeln(`Branch: ${branch}`)
|
|
114
|
+
|
|
115
|
+
// ─── Step 2: Governance ────────────────────────────────────────────────────
|
|
116
|
+
// push never reads the index — no doc-only exception. Governance is either
|
|
117
|
+
// skipped (chore/docs) or enforced (feat/fix/refactor), nothing in between.
|
|
118
|
+
if (!isGatedShipBranch(branch)) {
|
|
119
|
+
// chore/docs: housekeeping types already exempted from this gate by
|
|
120
|
+
// `trackfw branch new` and `trackfw commit` — push without it too.
|
|
121
|
+
writeln('Governance: skipped (chore/docs branch)')
|
|
122
|
+
} else {
|
|
123
|
+
const violations = checkGovernanceFn()
|
|
124
|
+
if (violations.length > 0) {
|
|
125
|
+
writeln('\nGovernance check failed:')
|
|
126
|
+
for (const v of violations) {
|
|
127
|
+
writeln(` ${v}`)
|
|
128
|
+
}
|
|
129
|
+
writeln('\nCreate the required artifacts before running push:')
|
|
130
|
+
writeln(' trackfw req new "<title>"')
|
|
131
|
+
writeln(' trackfw roadmap new "<title>"')
|
|
132
|
+
writeln(' trackfw roadmap move <name> wip')
|
|
133
|
+
writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
|
|
134
|
+
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
135
|
+
writeln("passes but 'trackfw push' aborts here, you likely have lenient mode")
|
|
136
|
+
writeln("configured — push always requires REQ + roadmap in wip/.")
|
|
137
|
+
writeErr(`governance check failed: ${violations.length} violation(s)`)
|
|
138
|
+
return 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
writeln('Governance: OK')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Step 2.5: force-with-lease gate ───────────────────────────────────────
|
|
145
|
+
// Runs before any write (push) — a refusal here never leaves the caller unable to push.
|
|
146
|
+
// Read-only, so it runs in --dry-run too.
|
|
147
|
+
if (opts.forceWithLease) {
|
|
148
|
+
const { stdout: gateRemoteURLRaw } = execGit(['remote', 'get-url', 'origin'])
|
|
149
|
+
const gateRemoteURL = (gateRemoteURLRaw || '').trim()
|
|
150
|
+
|
|
151
|
+
let resolution
|
|
152
|
+
try {
|
|
153
|
+
resolution = forgeResolve({
|
|
154
|
+
flagForge: '',
|
|
155
|
+
configForge: deps.configForge || '',
|
|
156
|
+
remoteURL: gateRemoteURL,
|
|
157
|
+
repoDir: deps.repoDir || '',
|
|
158
|
+
})
|
|
159
|
+
} catch (resErr) {
|
|
160
|
+
writeErr(resErr.message)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const adapter = forgeAdapter(resolution.forge, deps.availFn || undefined)
|
|
165
|
+
if (resolution.forge === 'manual' || !adapter.available) {
|
|
166
|
+
writeErr(PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG)
|
|
167
|
+
return 1
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const checkPROpen = deps.checkPROpen || defaultCheckPROpen
|
|
171
|
+
let open
|
|
172
|
+
try {
|
|
173
|
+
open = checkPROpen(adapter, branch)
|
|
174
|
+
} catch (prErr) {
|
|
175
|
+
writeErr(pushForceLeaseCannotVerifyMsg(branch, adapter.cliName, prErr.message))
|
|
176
|
+
return 1
|
|
177
|
+
}
|
|
178
|
+
if (!open) {
|
|
179
|
+
writeErr(pushForceLeaseNoPROpenMsg(branch))
|
|
180
|
+
return 1
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
writeln(`force-with-lease: open ${adapter.noun} confirmed for "${branch}" (${resolution.forge}).`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Step 3: Squash-merge detection ────────────────────────────────────────
|
|
187
|
+
if (opts.dryRun) {
|
|
188
|
+
writeln('[dry-run] git fetch origin --prune')
|
|
189
|
+
} else {
|
|
190
|
+
const { error: fetchErr } = execGit(['fetch', 'origin', '--prune'])
|
|
191
|
+
if (fetchErr) {
|
|
192
|
+
writeln('Warning: could not fetch origin (offline or no remote); skipping squash-merge check.')
|
|
193
|
+
} else {
|
|
194
|
+
detectPendingSquashMerges(branch, execGit, writeln)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── Step 4: Push ──────────────────────────────────────────────────────────
|
|
199
|
+
let pushArgs = buildPushArgs(branch, execGit)
|
|
200
|
+
if (opts.forceWithLease) {
|
|
201
|
+
// Fixed position: push --force-with-lease [-u] origin <branch> — identical
|
|
202
|
+
// across the 3 CLIs (the parity gate compares this literally).
|
|
203
|
+
pushArgs = [pushArgs[0], '--force-with-lease', ...pushArgs.slice(1)]
|
|
204
|
+
}
|
|
205
|
+
const { error: pushErr } = git(pushArgs)
|
|
206
|
+
if (pushErr) {
|
|
207
|
+
writeErr(`git push failed: ${pushErr.message}`)
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!opts.dryRun) {
|
|
212
|
+
writeln(`Pushed: ${branch} → origin/${branch}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return 0
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = {
|
|
219
|
+
runPush,
|
|
220
|
+
PUSH_FORCE_LEASE_NO_FORGE_CLI_MSG,
|
|
221
|
+
pushForceLeaseNoPROpenMsg,
|
|
222
|
+
pushForceLeaseCannotVerifyMsg,
|
|
223
|
+
}
|