trackfw 6.9.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -3
- package/package.json +1 -1
- package/src/branch/runner.js +33 -20
- package/src/changelog/index.js +114 -0
- package/src/commands/branch.js +12 -9
- package/src/commands/changelog.js +29 -0
- package/src/commands/commit.js +60 -0
- package/src/commands/discover.js +69 -0
- package/src/commands/index.js +22 -4
- package/src/commands/integrations.js +7 -0
- package/src/commands/ship.js +4 -3
- package/src/commands/thirdparty.js +384 -0
- package/src/commands/update-harness.js +3 -1
- 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 +479 -2
- package/src/generators/init.js +28 -6
- package/src/i18n/locales/en-US.json +1 -19
- package/src/i18n/locales/es-ES.json +1 -19
- package/src/i18n/locales/pt-BR.json +1 -19
- package/src/integrations/index.js +19 -2
- package/src/integrations/manager.js +15 -2
- package/src/integrations/render.js +102 -4
- package/src/lib/unknown-command.js +82 -0
- package/src/ship/runner.js +213 -26
- 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 +715 -16
- package/src/commands/plugins.js +0 -184
package/src/ship/runner.js
CHANGED
|
@@ -28,11 +28,26 @@ function isGitWriteCmd(args) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* isShipBranch returns true when branch matches feat|fix|refactor/<slug
|
|
31
|
+
* isShipBranch returns true when branch matches feat|fix|refactor|chore|docs/<slug> — the full
|
|
32
|
+
* vocabulary `trackfw ship` accepts on the branch name. feat/fix/refactor are gated on Step 2's
|
|
33
|
+
* branch_has_wip_roadmap governance check (a hard gate not affected by lenient mode); chore/docs
|
|
34
|
+
* are housekeeping types — already exempted from that gate by `trackfw branch new` and
|
|
35
|
+
* `trackfw commit` — and ship without it too.
|
|
32
36
|
* @param {string} branch
|
|
33
37
|
* @returns {boolean}
|
|
34
38
|
*/
|
|
35
39
|
function isShipBranch(branch) {
|
|
40
|
+
return /^(feat|fix|refactor|chore|docs)\/.+/.test(branch)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* isGatedShipBranch returns true when branch matches feat|fix|refactor/<slug> — the subset of
|
|
45
|
+
* isShipBranch's vocabulary that requires Step 2's branch_has_wip_roadmap governance check.
|
|
46
|
+
* chore/docs branches satisfy isShipBranch but return false here.
|
|
47
|
+
* @param {string} branch
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function isGatedShipBranch(branch) {
|
|
36
51
|
return /^(feat|fix|refactor)\/.+/.test(branch)
|
|
37
52
|
}
|
|
38
53
|
|
|
@@ -78,8 +93,10 @@ function checkShipGovernance() {
|
|
|
78
93
|
const branchResult = defaultExecGit(['symbolic-ref', '--short', 'HEAD'])
|
|
79
94
|
const branch = branchResult.error ? '' : branchResult.stdout.trim()
|
|
80
95
|
|
|
81
|
-
// Check branch has matching roadmap in wip/
|
|
82
|
-
|
|
96
|
+
// Check branch has matching roadmap in wip/ — gated types only (feat/fix/refactor); chore/docs
|
|
97
|
+
// are exempted by the outer runShip Step 2 skip and never reach this function in practice, but
|
|
98
|
+
// isGatedShipBranch keeps this check correct in isolation too.
|
|
99
|
+
if (branch && isGatedShipBranch(branch)) {
|
|
83
100
|
const slug = normalizeBranchSlug(branch.split('/').slice(1).join('/'))
|
|
84
101
|
let hasMatch = false
|
|
85
102
|
let wipFiles = []
|
|
@@ -217,6 +234,137 @@ function firstLine(s) {
|
|
|
217
234
|
return idx >= 0 ? s.slice(0, idx) : s
|
|
218
235
|
}
|
|
219
236
|
|
|
237
|
+
/**
|
|
238
|
+
* splitNonEmptyLines splits git output (e.g. `diff --cached --name-only`) into an array
|
|
239
|
+
* of trimmed, non-empty lines.
|
|
240
|
+
* @param {string} s
|
|
241
|
+
* @returns {string[]}
|
|
242
|
+
*/
|
|
243
|
+
function splitNonEmptyLines(s) {
|
|
244
|
+
const trimmed = (s || '').trim()
|
|
245
|
+
if (!trimmed) return []
|
|
246
|
+
return trimmed
|
|
247
|
+
.split('\n')
|
|
248
|
+
.map((line) => line.trim())
|
|
249
|
+
.filter((line) => line !== '')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* allDocOnly returns true when there is at least one staged file and every staged file is
|
|
254
|
+
* doc-only: under docs/ or vault/ (path prefix), or has a .md extension. A single file
|
|
255
|
+
* outside that criterion makes it return false. Mirrors the doc-only exception documented
|
|
256
|
+
* in CLAUDE.md §7 ("Alteração doc-only (markdown, comentários)").
|
|
257
|
+
* @param {string[]} files
|
|
258
|
+
* @returns {boolean}
|
|
259
|
+
*/
|
|
260
|
+
function allDocOnly(files) {
|
|
261
|
+
if (!files || files.length === 0) return false
|
|
262
|
+
for (const f of files) {
|
|
263
|
+
if (f.startsWith('docs/') || f.startsWith('vault/') || f.endsWith('.md')) continue
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* commitMessageSep delimits full commit messages (%B) in the output of gitCommitsSince's
|
|
271
|
+
* `git log --format=%B<sep>`. Same non-printable separator used by the Go implementation
|
|
272
|
+
* (internal/commands/ship.go) — cannot appear in a real commit message.
|
|
273
|
+
*/
|
|
274
|
+
const COMMIT_MESSAGE_SEP = '\x1e'
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* defaultBaseBranch resolves the repository's default branch for `git log <base>..HEAD`.
|
|
278
|
+
* Tries `git symbolic-ref refs/remotes/origin/HEAD` (format "refs/remotes/origin/main" —
|
|
279
|
+
* only the name after the last slash is kept) and falls back to "main" when that fails or
|
|
280
|
+
* yields nothing.
|
|
281
|
+
* @param {function} execGit
|
|
282
|
+
* @returns {string}
|
|
283
|
+
*/
|
|
284
|
+
function defaultBaseBranch(execGit) {
|
|
285
|
+
const { stdout, error } = execGit(['symbolic-ref', 'refs/remotes/origin/HEAD'])
|
|
286
|
+
if (error) return 'main'
|
|
287
|
+
const out = (stdout || '').trim()
|
|
288
|
+
const idx = out.lastIndexOf('/')
|
|
289
|
+
if (idx < 0 || idx + 1 >= out.length) return 'main'
|
|
290
|
+
return out.slice(idx + 1)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* gitCommitsSince returns the full message (subject + body) of every non-merge commit in
|
|
295
|
+
* base..HEAD, most-recent-first (git log's natural order). Returns [] on any git error or
|
|
296
|
+
* when the range is empty.
|
|
297
|
+
* @param {string} base
|
|
298
|
+
* @param {function} execGit
|
|
299
|
+
* @returns {string[]}
|
|
300
|
+
*/
|
|
301
|
+
function gitCommitsSince(base, execGit) {
|
|
302
|
+
const { stdout, error } = execGit(['log', `${base}..HEAD`, '--no-merges', `--format=%B${COMMIT_MESSAGE_SEP}`])
|
|
303
|
+
if (error) return []
|
|
304
|
+
const out = (stdout || '').trim()
|
|
305
|
+
if (!out) return []
|
|
306
|
+
const parts = out.split(COMMIT_MESSAGE_SEP)
|
|
307
|
+
const commits = []
|
|
308
|
+
for (let p of parts) {
|
|
309
|
+
p = p.replace(/^\n+|\n+$/g, '')
|
|
310
|
+
if (p.trim() === '') continue
|
|
311
|
+
commits.push(p)
|
|
312
|
+
}
|
|
313
|
+
return commits
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* buildPRBody constructs the PR/MR body. With 0 or 1 non-merge commit on the branch (the
|
|
318
|
+
* trivial case — just the commit `ship` itself made), it keeps the original minimal body,
|
|
319
|
+
* not a regression. With 2+ commits, it aggregates the branch's commit history:
|
|
320
|
+
*
|
|
321
|
+
* ## Commits
|
|
322
|
+
* - <subject of commit 1>
|
|
323
|
+
* - <subject of commit 2>
|
|
324
|
+
*
|
|
325
|
+
* ## Detalhes
|
|
326
|
+
* <full body of each commit that has one, in blocks>
|
|
327
|
+
*
|
|
328
|
+
* ---
|
|
329
|
+
* Branch: <branch>
|
|
330
|
+
*
|
|
331
|
+
* @param {string} branch
|
|
332
|
+
* @param {string[]} commits
|
|
333
|
+
* @returns {string}
|
|
334
|
+
*/
|
|
335
|
+
function buildPRBody(branch, commits) {
|
|
336
|
+
if (!commits || commits.length <= 1) {
|
|
337
|
+
return `Branch: ${branch}\n\nCreated by trackfw ship.`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const subjects = []
|
|
341
|
+
const details = []
|
|
342
|
+
for (const c of commits) {
|
|
343
|
+
const nlIdx = c.indexOf('\n')
|
|
344
|
+
const subject = (nlIdx >= 0 ? c.slice(0, nlIdx) : c).trim()
|
|
345
|
+
if (subject === '') continue
|
|
346
|
+
subjects.push(subject)
|
|
347
|
+
if (nlIdx >= 0) {
|
|
348
|
+
const bodyText = c.slice(nlIdx + 1).trim()
|
|
349
|
+
if (bodyText !== '') {
|
|
350
|
+
details.push(`**${subject}**\n\n${bodyText}`)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let b = '## Commits\n\n'
|
|
356
|
+
for (const s of subjects) {
|
|
357
|
+
b += `- ${s}\n`
|
|
358
|
+
}
|
|
359
|
+
if (details.length > 0) {
|
|
360
|
+
b += '\n## Detalhes\n\n'
|
|
361
|
+
b += details.join('\n\n')
|
|
362
|
+
b += '\n'
|
|
363
|
+
}
|
|
364
|
+
b += `\n---\nBranch: ${branch}\n`
|
|
365
|
+
return b
|
|
366
|
+
}
|
|
367
|
+
|
|
220
368
|
/**
|
|
221
369
|
* buildForgeCreateArgs appends --title and --body (or --description for azure)
|
|
222
370
|
* to a copy of adapter.cliArgs. Never mutates the original array.
|
|
@@ -258,6 +406,13 @@ function runShip(opts, deps = {}) {
|
|
|
258
406
|
return execGit(args)
|
|
259
407
|
}
|
|
260
408
|
|
|
409
|
+
// ─── Step 0: staged files ───────────────────────────────────────────────────
|
|
410
|
+
// Read once, up front, so Steps 1 and 2 can grant a doc-only exception before they run —
|
|
411
|
+
// and so Step 4 below reuses the same read instead of querying git twice.
|
|
412
|
+
const { stdout: stagedOut } = execGit(['diff', '--cached', '--name-only'])
|
|
413
|
+
const stagedFiles = splitNonEmptyLines(stagedOut)
|
|
414
|
+
const docOnly = allDocOnly(stagedFiles)
|
|
415
|
+
|
|
261
416
|
// ─── Step 1: Branch validation ─────────────────────────────────────────────
|
|
262
417
|
const branchResult = execGit(['symbolic-ref', '--short', 'HEAD'])
|
|
263
418
|
if (branchResult.error) {
|
|
@@ -266,14 +421,15 @@ function runShip(opts, deps = {}) {
|
|
|
266
421
|
}
|
|
267
422
|
const branch = branchResult.stdout.trim()
|
|
268
423
|
|
|
424
|
+
// main/master is blocked unconditionally — the doc-only exception never applies here.
|
|
269
425
|
if (branch === 'main' || branch === 'master') {
|
|
270
426
|
writeln(`error: trackfw ship cannot run on "${branch}" — use a feature branch:\n git checkout -b feat/<slug>`)
|
|
271
427
|
return 1
|
|
272
428
|
}
|
|
273
429
|
|
|
274
|
-
if (!isShipBranch(branch)) {
|
|
430
|
+
if (!docOnly && !isShipBranch(branch)) {
|
|
275
431
|
writeln(
|
|
276
|
-
`error: branch "${branch}" does not match the required pattern feat|fix|refactor/<slug>\n` +
|
|
432
|
+
`error: branch "${branch}" does not match the required pattern feat|fix|refactor|chore|docs/<slug>\n` +
|
|
277
433
|
'Rename your branch or create a new one:\n git checkout -b feat/<slug>'
|
|
278
434
|
)
|
|
279
435
|
return 1
|
|
@@ -282,25 +438,35 @@ function runShip(opts, deps = {}) {
|
|
|
282
438
|
writeln(`Branch: ${branch}`)
|
|
283
439
|
|
|
284
440
|
// ─── Step 2: Governance ────────────────────────────────────────────────────
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
441
|
+
// Doc-only changes (all staged files under docs/, vault/, or *.md) are exempt from
|
|
442
|
+
// REQ+roadmap governance — mirrors the CLAUDE.md §7 exception for doc-only changes.
|
|
443
|
+
if (docOnly) {
|
|
444
|
+
writeln('Governance: skipped (doc-only change)')
|
|
445
|
+
} else if (isShipBranch(branch) && !isGatedShipBranch(branch)) {
|
|
446
|
+
// chore/docs: housekeeping types already exempted from this gate by
|
|
447
|
+
// `trackfw branch new` and `trackfw commit` — ship without it too.
|
|
448
|
+
writeln('Governance: skipped (chore/docs branch)')
|
|
449
|
+
} else {
|
|
450
|
+
const violations = checkGovernanceFn()
|
|
451
|
+
if (violations.length > 0) {
|
|
452
|
+
writeln('\nGovernance check failed:')
|
|
453
|
+
for (const v of violations) {
|
|
454
|
+
writeln(` ${v}`)
|
|
455
|
+
}
|
|
456
|
+
writeln('\nCreate the required artifacts before running ship:')
|
|
457
|
+
writeln(' trackfw req new "<title>"')
|
|
458
|
+
writeln(' trackfw roadmap new "<title>"')
|
|
459
|
+
writeln(' trackfw roadmap move <name> wip')
|
|
460
|
+
writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
|
|
461
|
+
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
462
|
+
writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
|
|
463
|
+
writeln("configured — ship always requires REQ + roadmap in wip/.")
|
|
464
|
+
writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
|
|
465
|
+
return 1
|
|
290
466
|
}
|
|
291
|
-
writeln('\nCreate the required artifacts before running ship:')
|
|
292
|
-
writeln(' trackfw req new "<title>"')
|
|
293
|
-
writeln(' trackfw roadmap new "<title>"')
|
|
294
|
-
writeln(' trackfw roadmap move <name> wip')
|
|
295
|
-
writeln("\nNote: this governance check is a hard gate — it is not affected by lenient")
|
|
296
|
-
writeln("mode or per-rule severity configured in trackfw.yaml. If 'trackfw validate'")
|
|
297
|
-
writeln("passes but 'trackfw ship' aborts here, you likely have lenient mode")
|
|
298
|
-
writeln("configured — ship always requires REQ + roadmap in wip/.")
|
|
299
|
-
writeln(`\nerror: governance check failed: ${violations.length} violation(s)`)
|
|
300
|
-
return 1
|
|
301
|
-
}
|
|
302
467
|
|
|
303
|
-
|
|
468
|
+
writeln('Governance: OK')
|
|
469
|
+
}
|
|
304
470
|
|
|
305
471
|
// ─── Step 3: Squash-merge detection ────────────────────────────────────────
|
|
306
472
|
if (opts.dryRun) {
|
|
@@ -323,8 +489,8 @@ function runShip(opts, deps = {}) {
|
|
|
323
489
|
if (diffStatOut) writeln(diffStatOut)
|
|
324
490
|
writeln('────────────────────────────────────────────────────────\n')
|
|
325
491
|
|
|
326
|
-
|
|
327
|
-
if (
|
|
492
|
+
// Reuses stagedFiles read at the top of the function (Step 0) — never re-query git here.
|
|
493
|
+
if (stagedFiles.length === 0) {
|
|
328
494
|
writeln(
|
|
329
495
|
'error: nothing is staged — stage your files explicitly before running ship:\n' +
|
|
330
496
|
' git add <file1> <file2> ...\n' +
|
|
@@ -391,7 +557,23 @@ function runShip(opts, deps = {}) {
|
|
|
391
557
|
return 0
|
|
392
558
|
}
|
|
393
559
|
|
|
560
|
+
// Title/body computed once for every remaining branch below (dry-run and real CLI
|
|
561
|
+
// invocation alike). git log/diff are read-only — they run in --dry-run mode too, same
|
|
562
|
+
// as the staged-files read in Step 0.
|
|
563
|
+
//
|
|
564
|
+
// Design decision (documented per roadmap ML-1A, mirrored from the Go implementation):
|
|
565
|
+
// the title is always firstLine(opts.message), the -m message passed to this very `ship`
|
|
566
|
+
// call, even when the branch carries multiple prior commits. Deriving a distinct "PR
|
|
567
|
+
// title" from N unrelated commit subjects would need a heuristic with no unambiguous
|
|
568
|
+
// answer — the simplest, least surprising rule is that -m is the PR's summary.
|
|
569
|
+
const base = defaultBaseBranch(execGit)
|
|
570
|
+
const commits = gitCommitsSince(base, execGit)
|
|
571
|
+
const title = firstLine(opts.message || '')
|
|
572
|
+
const body = buildPRBody(branch, commits)
|
|
573
|
+
|
|
394
574
|
if (opts.dryRun) {
|
|
575
|
+
writeln(`[dry-run] Title: ${title}`)
|
|
576
|
+
writeln(`[dry-run] Body:\n${body}`)
|
|
395
577
|
if (!adapter.available && resolution.forge !== 'manual') {
|
|
396
578
|
const url = adapter.fallbackURL(remoteURL, branch)
|
|
397
579
|
if (url) {
|
|
@@ -423,8 +605,6 @@ function runShip(opts, deps = {}) {
|
|
|
423
605
|
}
|
|
424
606
|
|
|
425
607
|
// CLI is available — invoke it.
|
|
426
|
-
const title = firstLine(opts.message || '')
|
|
427
|
-
const body = `Branch: ${branch}\n\nCreated by trackfw ship.`
|
|
428
608
|
const cliArgs = buildForgeCreateArgs(adapter, title, body)
|
|
429
609
|
const execForgeCLI = deps.execForgeCLI || defaultExecForgeCLI
|
|
430
610
|
const cliErr = execForgeCLI(adapter.cliName, cliArgs)
|
|
@@ -443,6 +623,7 @@ function runShip(opts, deps = {}) {
|
|
|
443
623
|
module.exports = {
|
|
444
624
|
runShip,
|
|
445
625
|
isShipBranch,
|
|
626
|
+
isGatedShipBranch,
|
|
446
627
|
isGitWriteCmd,
|
|
447
628
|
normalizeBranchSlug,
|
|
448
629
|
checkShipGovernance,
|
|
@@ -451,4 +632,10 @@ module.exports = {
|
|
|
451
632
|
GIT_WRITE_COMMANDS,
|
|
452
633
|
buildForgeCreateArgs,
|
|
453
634
|
firstLine,
|
|
635
|
+
allDocOnly,
|
|
636
|
+
splitNonEmptyLines,
|
|
637
|
+
defaultBaseBranch,
|
|
638
|
+
gitCommitsSince,
|
|
639
|
+
buildPRBody,
|
|
640
|
+
COMMIT_MESSAGE_SEP,
|
|
454
641
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/fetch.go — the network fetch primitive
|
|
4
|
+
// used by the two-phase third-party artifact gate (skills/agents installed
|
|
5
|
+
// from a URL). See:
|
|
6
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
7
|
+
// (D7).
|
|
8
|
+
|
|
9
|
+
const https = require('node:https')
|
|
10
|
+
const { URL } = require('node:url')
|
|
11
|
+
|
|
12
|
+
// MAX_CONTENT_SIZE is the network fetch cap for third-party text artifacts
|
|
13
|
+
// (D7): 2 MiB — mirrors internal/thirdparty/fetch.go:maxContentSize.
|
|
14
|
+
const MAX_CONTENT_SIZE = 2 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
// MAX_REDIRECTS mirrors internal/thirdparty/fetch.go:maxRedirects (3). Note
|
|
17
|
+
// the Go reference's own net/http.Client.CheckRedirect semantics: the check
|
|
18
|
+
// runs BEFORE following a redirect and compares the count of requests
|
|
19
|
+
// ALREADY COMPLETED (via.length, which includes the request that just
|
|
20
|
+
// returned the redirect) against maxRedirects — so in practice only
|
|
21
|
+
// (maxRedirects - 1) redirects are ever followed before the next one is
|
|
22
|
+
// refused. Reproduced here byte-for-byte via requestsCompleted, not
|
|
23
|
+
// "fixed" to what the doc comment alone might suggest.
|
|
24
|
+
const MAX_REDIRECTS = 3
|
|
25
|
+
|
|
26
|
+
const TIMEOUT_MS = 30 * 1000
|
|
27
|
+
|
|
28
|
+
// ALLOWED_CONTENT_TYPES mirrors internal/thirdparty/fetch.go:allowedContentTypes.
|
|
29
|
+
const ALLOWED_CONTENT_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-markdown'])
|
|
30
|
+
|
|
31
|
+
// contentTypeAllowed mirrors internal/thirdparty/fetch.go:contentTypeAllowed
|
|
32
|
+
// — tolerates an optional "; charset=..." (or any other parameter) suffix.
|
|
33
|
+
function contentTypeAllowed(contentType) {
|
|
34
|
+
const raw = String(contentType || '')
|
|
35
|
+
const semi = raw.indexOf(';')
|
|
36
|
+
const base = (semi >= 0 ? raw.slice(0, semi) : raw).trim().toLowerCase()
|
|
37
|
+
return ALLOWED_CONTENT_TYPES.has(base)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// api is the module.exports object itself, referenced internally via
|
|
41
|
+
// `api.requestOnce(...)` so tests can substitute the single HTTP round
|
|
42
|
+
// trip without a real TLS socket — mirrors the thirdPartyFetch
|
|
43
|
+
// package-var substitution pattern already used at the command layer
|
|
44
|
+
// (npm/src/commands/thirdparty.js). Added in ML-4C to close a coverage
|
|
45
|
+
// gap hefesto-tf found: the resp.statusCode !== 200 branch below existed
|
|
46
|
+
// but was never exercised by any test.
|
|
47
|
+
const api = {}
|
|
48
|
+
|
|
49
|
+
function requestOnce(rawURL) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const req = https.get(rawURL, { timeout: TIMEOUT_MS }, res => {
|
|
52
|
+
const chunks = []
|
|
53
|
+
let total = 0
|
|
54
|
+
let tooLarge = false
|
|
55
|
+
res.on('data', chunk => {
|
|
56
|
+
if (tooLarge) return
|
|
57
|
+
total += chunk.length
|
|
58
|
+
if (total > MAX_CONTENT_SIZE) {
|
|
59
|
+
tooLarge = true
|
|
60
|
+
res.destroy()
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
chunks.push(chunk)
|
|
64
|
+
})
|
|
65
|
+
res.on('end', () => {
|
|
66
|
+
resolve({ statusCode: res.statusCode, headers: res.headers, body: Buffer.concat(chunks), tooLarge })
|
|
67
|
+
})
|
|
68
|
+
res.on('error', err => reject(new Error(`failed to read response body: ${err.message}`)))
|
|
69
|
+
})
|
|
70
|
+
req.on('timeout', () => req.destroy(new Error('fetch failed: request timed out after 30s')))
|
|
71
|
+
req.on('error', err => reject(new Error(`fetch failed: ${err.message}`)))
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// fetchOnce follows redirects with the same accounting as Go's
|
|
76
|
+
// fetchClient.CheckRedirect (see MAX_REDIRECTS doc above). requestsCompleted
|
|
77
|
+
// starts at 0 and is incremented after every completed HTTP request.
|
|
78
|
+
async function fetchOnce(rawURL, requestsCompleted) {
|
|
79
|
+
let parsed
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(rawURL)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`invalid URL "${rawURL}": ${err.message}`)
|
|
84
|
+
}
|
|
85
|
+
if (parsed.protocol !== 'https:') {
|
|
86
|
+
throw new Error(`refused: URL scheme must be https, got "${parsed.protocol.replace(/:$/, '')}"`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = await api.requestOnce(rawURL)
|
|
90
|
+
requestsCompleted += 1
|
|
91
|
+
|
|
92
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
93
|
+
if (requestsCompleted >= MAX_REDIRECTS) {
|
|
94
|
+
throw new Error(`fetch failed: stopped after ${MAX_REDIRECTS} redirects`)
|
|
95
|
+
}
|
|
96
|
+
let next
|
|
97
|
+
try {
|
|
98
|
+
next = new URL(res.headers.location, rawURL).toString()
|
|
99
|
+
} catch (err) {
|
|
100
|
+
throw new Error(`invalid redirect location: ${err.message}`)
|
|
101
|
+
}
|
|
102
|
+
const nextScheme = new URL(next).protocol
|
|
103
|
+
if (nextScheme !== 'https:') {
|
|
104
|
+
throw new Error(`redirect to non-https URL refused: ${next}`)
|
|
105
|
+
}
|
|
106
|
+
return fetchOnce(next, requestsCompleted)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (res.statusCode !== 200) {
|
|
110
|
+
throw new Error(`fetch failed: HTTP ${res.statusCode} for ${rawURL}`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const contentType = res.headers['content-type']
|
|
114
|
+
if (!contentTypeAllowed(contentType)) {
|
|
115
|
+
throw new Error(`refused: unsupported Content-Type "${contentType || ''}" (allowed: text/plain, text/markdown, text/x-markdown)`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (res.tooLarge) {
|
|
119
|
+
throw new Error(`refused: content exceeds ${MAX_CONTENT_SIZE} bytes`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return res.body
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// fetch downloads the content at rawURL under the D7 network policy:
|
|
126
|
+
// https-only (validated before the first request), a 30s timeout, at most
|
|
127
|
+
// (MAX_REDIRECTS - 1) redirect hops (each revalidated for https), a 2 MiB
|
|
128
|
+
// size cap, and a Content-Type allowlist. Mirrors
|
|
129
|
+
// internal/thirdparty/fetch.go:Fetch.
|
|
130
|
+
async function fetch(rawURL) {
|
|
131
|
+
return fetchOnce(rawURL, 0)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
api.fetch = fetch
|
|
135
|
+
api.contentTypeAllowed = contentTypeAllowed
|
|
136
|
+
api.requestOnce = requestOnce
|
|
137
|
+
api.MAX_CONTENT_SIZE = MAX_CONTENT_SIZE
|
|
138
|
+
api.MAX_REDIRECTS = MAX_REDIRECTS
|
|
139
|
+
|
|
140
|
+
module.exports = api
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Node port of internal/thirdparty/markers.go — the D3 objective-refusal
|
|
4
|
+
// marker check and the Checksum primitive. See:
|
|
5
|
+
// docs/adr/ADR-2026-08-15-gate-de-duas-fases-para-artefatos-de-terceiro-quarentena-parecer-vinculado-por-checksum-e-deteccao-por-proveniencia-versionada.md
|
|
6
|
+
// (D3, D6).
|
|
7
|
+
|
|
8
|
+
const crypto = require('node:crypto')
|
|
9
|
+
const { URL } = require('node:url')
|
|
10
|
+
|
|
11
|
+
// LITERAL_MARKERS mirrors internal/thirdparty/markers.go:literalMarkers — an
|
|
12
|
+
// objective, literal tripwire list, not a filter against a competent
|
|
13
|
+
// adversary (see the ADR's "o que este critério NÃO cobre" section).
|
|
14
|
+
const LITERAL_MARKERS = [
|
|
15
|
+
'git authority',
|
|
16
|
+
'mode lock',
|
|
17
|
+
'governance prerequisite',
|
|
18
|
+
'reporting boundary',
|
|
19
|
+
'scope boundary',
|
|
20
|
+
'dispatch contract',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// HTML_COMMENT_PATTERN matches HTML comments; step 1 of the D3 pipeline
|
|
24
|
+
// NEUTRALIZES them (strips only the delimiters, keeping the inner content
|
|
25
|
+
// in place to be scanned), mirroring internal/thirdparty/markers.go's
|
|
26
|
+
// D3-ter(b) amendment — see neutralizeHTMLComments below.
|
|
27
|
+
const HTML_COMMENT_PATTERN = /<!--([\s\S]*?)-->/g
|
|
28
|
+
|
|
29
|
+
// neutralizeHTMLComments strips only the HTML comment delimiters ("<!--"
|
|
30
|
+
// and "-->"), keeping whatever text was between them in place to be
|
|
31
|
+
// scanned by the later steps of the pipeline. D3-ter(b): the previous
|
|
32
|
+
// wholesale removal contradicted D3's own written justification for this
|
|
33
|
+
// step ("an LLM reads HTML comments in the token stream") — a marker
|
|
34
|
+
// hidden as `<!-- ## Git authority -->` passed clean.
|
|
35
|
+
function neutralizeHTMLComments(text) {
|
|
36
|
+
return text.replace(HTML_COMMENT_PATTERN, '$1')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// FENCE_PREFIX_PATTERN detects a fence-opening/closing line: optional
|
|
40
|
+
// leading whitespace followed by three or more backticks or tildes.
|
|
41
|
+
//
|
|
42
|
+
// Unlike the Go reference — which is forced into an explicit line-scanner
|
|
43
|
+
// because Go's RE2-based regexp package rejects backreferences at runtime
|
|
44
|
+
// (see vault/notes/go-regexp-re2-sem-backreference-fenced-block-removal-2026-08-15.md)
|
|
45
|
+
// — JS RegExp does support backreferences. This port still uses the same
|
|
46
|
+
// explicit line-scanner algorithm as the Go reference (removeFencedBlocks
|
|
47
|
+
// below), NOT because JS requires it, but because the scanner is what
|
|
48
|
+
// implements the actual CommonMark closing rule correctly (same delimiter
|
|
49
|
+
// character, closer repeat count >= opener repeat count, unclosed fence
|
|
50
|
+
// never re-opens a heading read). A single backreference regex here would
|
|
51
|
+
// diverge from the Go reference on: an unclosed fence, a closing run
|
|
52
|
+
// shorter than the opener, and an indented fence — porting the algorithm,
|
|
53
|
+
// not the regex, is what keeps behavior byte-identical to Go.
|
|
54
|
+
const FENCE_PREFIX_PATTERN = /^\s*(```+|~~~+)/
|
|
55
|
+
|
|
56
|
+
// trimChar mirrors Go's strings.Trim(s, cutset) for a single-char cutset:
|
|
57
|
+
// strips ONLY leading/trailing runs of ch, never occurrences in the middle.
|
|
58
|
+
function trimChar(str, ch) {
|
|
59
|
+
let start = 0
|
|
60
|
+
let end = str.length
|
|
61
|
+
while (start < end && str[start] === ch) start++
|
|
62
|
+
while (end > start && str[end - 1] === ch) end--
|
|
63
|
+
return str.slice(start, end)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// removeFencedBlocks strips PROPERLY-CLOSED fenced code blocks (``` or
|
|
67
|
+
// ~~~), step 2 of the D3 pipeline: lines inside a closed fence are not read
|
|
68
|
+
// as headings, otherwise documentation that merely quotes the marker list
|
|
69
|
+
// would be refused by its own criterion. A fence is closed by a line
|
|
70
|
+
// starting with the same delimiter character, with at least as many
|
|
71
|
+
// repeats as the opener — the CommonMark rule.
|
|
72
|
+
//
|
|
73
|
+
// D3-ter(a) amendment: an opener with NO matching closer before EOF is NOT
|
|
74
|
+
// a fence for this check — the buffered lines (including the opener) are
|
|
75
|
+
// replayed as ordinary text instead of being dropped. Before this
|
|
76
|
+
// amendment, an unclosed fence swallowed the rest of the document as
|
|
77
|
+
// "fenced" content, silently hiding any marker after it. Mirrors
|
|
78
|
+
// internal/thirdparty/markers.go:removeFencedBlocks.
|
|
79
|
+
function removeFencedBlocks(text) {
|
|
80
|
+
const lines = text.split('\n')
|
|
81
|
+
const out = []
|
|
82
|
+
let buffered = [] // lines consumed since the current fence opener; replayed verbatim if it never closes
|
|
83
|
+
let closer = '' // fence delimiter run that closes the current block, '' if not in a fence
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (closer === '') {
|
|
86
|
+
const match = FENCE_PREFIX_PATTERN.exec(line)
|
|
87
|
+
if (match) {
|
|
88
|
+
closer = match[1]
|
|
89
|
+
buffered = [line] // keep the opener in case this fence never closes
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
out.push(line)
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
// Inside a (possibly-never-closing) fence: buffer the line, then check
|
|
96
|
+
// if it closes the block.
|
|
97
|
+
buffered.push(line)
|
|
98
|
+
const trimmed = line.trim()
|
|
99
|
+
const delimChar = closer[0]
|
|
100
|
+
if (trimmed.startsWith(delimChar.repeat(closer.length)) && trimChar(trimmed, delimChar) === '') {
|
|
101
|
+
closer = ''
|
|
102
|
+
buffered = [] // closed properly: the buffered fenced content is discarded, as before
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (closer !== '') {
|
|
106
|
+
// Reached EOF still "inside" a fence that never closed (D3-ter(a)): not
|
|
107
|
+
// a fence at all — replay every buffered line, including the opener,
|
|
108
|
+
// as ordinary text to be scanned.
|
|
109
|
+
out.push(...buffered)
|
|
110
|
+
}
|
|
111
|
+
return out.join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// HEADING_LINE_PATTERN matches a single, already-collapsed Markdown heading
|
|
115
|
+
// line (level 1-6). Applied per-line after step 5, on text that no longer
|
|
116
|
+
// contains internal runs of whitespace.
|
|
117
|
+
const HEADING_LINE_PATTERN = /^#{1,6}\s+(.*)$/
|
|
118
|
+
|
|
119
|
+
// WHITESPACE_PATTERN collapses runs of internal whitespace, step 5.
|
|
120
|
+
const WHITESPACE_PATTERN = /\s+/g
|
|
121
|
+
|
|
122
|
+
// checkMarkers applies the D3 objective-refusal criterion to content and
|
|
123
|
+
// returns the literal marker names (from LITERAL_MARKERS) that matched as a
|
|
124
|
+
// heading. The normalization pipeline, in fixed order, mirrors
|
|
125
|
+
// internal/thirdparty/markers.go:CheckMarkers exactly:
|
|
126
|
+
// 1. remove HTML comments;
|
|
127
|
+
// 2. remove fenced code blocks (``` and ~~~);
|
|
128
|
+
// 3. NFKC normalize;
|
|
129
|
+
// 4. casefold (String.prototype.toLowerCase(), matching Go's
|
|
130
|
+
// strings.ToLower — not a true Unicode casefold, deliberately, to stay
|
|
131
|
+
// byte-identical to the Go reference);
|
|
132
|
+
// 5. collapse internal whitespace + strip, applied per line;
|
|
133
|
+
// 6. match only lines matching ^#{1,6}\s+ against the literal marker list.
|
|
134
|
+
//
|
|
135
|
+
// content may be a Buffer or a string; Buffers are decoded as UTF-8,
|
|
136
|
+
// mirroring Go's string(content) over raw bytes.
|
|
137
|
+
function checkMarkers(content) {
|
|
138
|
+
let text = Buffer.isBuffer(content) ? content.toString('utf8') : String(content)
|
|
139
|
+
|
|
140
|
+
// 1. Neutralize HTML comments — strip only the delimiters, keep the
|
|
141
|
+
// inner content in place to be scanned (D3-ter(b)).
|
|
142
|
+
text = neutralizeHTMLComments(text)
|
|
143
|
+
|
|
144
|
+
// 2. Remove fenced code blocks — lines inside a fence are not headings.
|
|
145
|
+
text = removeFencedBlocks(text)
|
|
146
|
+
|
|
147
|
+
// 3. NFKC normalize.
|
|
148
|
+
text = text.normalize('NFKC')
|
|
149
|
+
|
|
150
|
+
// 4. Casefold.
|
|
151
|
+
text = text.toLowerCase()
|
|
152
|
+
|
|
153
|
+
const matched = []
|
|
154
|
+
const seen = new Set()
|
|
155
|
+
for (const line of text.split('\n')) {
|
|
156
|
+
// 5. Collapse internal whitespace + strip.
|
|
157
|
+
const collapsed = line.replace(WHITESPACE_PATTERN, ' ').trim()
|
|
158
|
+
|
|
159
|
+
// 6. Match only heading lines against the literal marker list.
|
|
160
|
+
const match = HEADING_LINE_PATTERN.exec(collapsed)
|
|
161
|
+
if (!match) continue
|
|
162
|
+
const body = match[1]
|
|
163
|
+
for (const marker of LITERAL_MARKERS) {
|
|
164
|
+
if (body === marker && !seen.has(marker)) {
|
|
165
|
+
matched.push(marker)
|
|
166
|
+
seen.add(marker)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return matched
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// checksum returns the SHA-256 hex digest of the raw bytes, before any
|
|
174
|
+
// normalization. Mirrors internal/thirdparty/markers.go:Checksum. raw may be
|
|
175
|
+
// a Buffer or a string (encoded as UTF-8).
|
|
176
|
+
function checksum(raw) {
|
|
177
|
+
const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf8')
|
|
178
|
+
return crypto.createHash('sha256').update(buf).digest('hex')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// redactURL returns rawURL with its query string — and userinfo, if
|
|
182
|
+
// present — replaced by the literal marker "[redacted]" (D6-bis). Used
|
|
183
|
+
// before persisting a third-party artifact's source URL to disk (the
|
|
184
|
+
// quarantine record and the provenance entry): a pre-signed URL can carry a
|
|
185
|
+
// bearer token in its query string, which would otherwise become a
|
|
186
|
+
// permanent secret in the git history the moment either file is committed.
|
|
187
|
+
// The full, unredacted URL is used only in memory, for the network fetch
|
|
188
|
+
// itself (D7) — never for anything persisted. Mirrors
|
|
189
|
+
// internal/thirdparty/markers.go:RedactURL. Deliberately constructs the
|
|
190
|
+
// result manually (protocol/host/pathname/hash) rather than mutating and
|
|
191
|
+
// re-serializing via the URL object's own .toString(), to stay
|
|
192
|
+
// byte-identical to the Go/Python output shape rather than depend on
|
|
193
|
+
// WHATWG URL serialization nuances.
|
|
194
|
+
function redactURL(rawURL) {
|
|
195
|
+
let parsed
|
|
196
|
+
try {
|
|
197
|
+
parsed = new URL(rawURL)
|
|
198
|
+
} catch {
|
|
199
|
+
return rawURL
|
|
200
|
+
}
|
|
201
|
+
let result = `${parsed.protocol}//${parsed.host}${parsed.pathname}`
|
|
202
|
+
if (parsed.search !== '') result += '?[redacted]'
|
|
203
|
+
result += parsed.hash
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { checkMarkers, checksum, redactURL, literalMarkers: LITERAL_MARKERS, removeFencedBlocks }
|