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.
@@ -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
- if (branch && isShipBranch(branch)) {
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
- const violations = checkGovernanceFn()
286
- if (violations.length > 0) {
287
- writeln('\nGovernance check failed:')
288
- for (const v of violations) {
289
- writeln(` ${v}`)
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
- writeln('Governance: OK')
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
- const { stdout: cachedFiles } = execGit(['diff', '--cached', '--name-only'])
327
- if (!cachedFiles.trim()) {
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 }