trackfw 6.10.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +0 -3
  2. package/bin/trackfw +38 -1
  3. package/package.json +1 -1
  4. package/src/branch/prune.js +450 -0
  5. package/src/branch/runner.js +33 -20
  6. package/src/changelog/index.js +114 -0
  7. package/src/commands/branch.js +59 -9
  8. package/src/commands/changelog.js +29 -0
  9. package/src/commands/commit.js +60 -0
  10. package/src/commands/discover.js +75 -0
  11. package/src/commands/doctor.js +32 -0
  12. package/src/commands/index.js +23 -4
  13. package/src/commands/integrations.js +23 -1
  14. package/src/commands/serve.js +52 -4
  15. package/src/commands/ship.js +4 -3
  16. package/src/commands/thirdparty.js +384 -0
  17. package/src/commands/update-harness.js +320 -12
  18. package/src/commands/update.js +3 -1
  19. package/src/commit/runner.js +352 -0
  20. package/src/config/index.js +27 -0
  21. package/src/generators/hooks.js +925 -6
  22. package/src/generators/init.js +28 -6
  23. package/src/i18n/locales/en-US.json +0 -21
  24. package/src/i18n/locales/es-ES.json +0 -21
  25. package/src/i18n/locales/pt-BR.json +0 -21
  26. package/src/integrations/doctor.js +0 -0
  27. package/src/integrations/index.js +19 -2
  28. package/src/integrations/manager.js +133 -19
  29. package/src/lib/fatal-error.js +64 -0
  30. package/src/lib/unknown-command.js +82 -0
  31. package/src/ship/runner.js +259 -95
  32. package/src/thirdparty/fetch.js +140 -0
  33. package/src/thirdparty/markers.js +207 -0
  34. package/src/thirdparty/provenance.js +114 -0
  35. package/src/thirdparty/quarantine.js +113 -0
  36. package/src/thirdparty/references.js +194 -0
  37. package/src/validator/index.js +1060 -27
  38. package/src/commands/plugins.js +0 -184
@@ -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 }
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ // Node port of internal/thirdparty/provenance.go — the D6/D8c provenance
4
+ // record and TOCTOU-closing approval check. 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
+ // (D6, D8c, D8f).
7
+
8
+ const fs = require('node:fs')
9
+ const path = require('node:path')
10
+
11
+ const { atomicWrite } = require('./quarantine')
12
+ const { redactURL } = require('./markers')
13
+
14
+ // PROVENANCE_SCHEMA_VERSION mirrors internal/thirdparty/provenance.go:provenanceSchemaVersion.
15
+ //
16
+ // Bumped 1 -> 2 (ADR-2026-08-15 D2-bis, ML-3B) to add the entry field
17
+ // installed_sha256 (SHA-256 of the NORMALIZED bytes, computed at install
18
+ // time). checksum_sha256 (SHA-256 of the RAW bytes, the D8c approval
19
+ // anchor) is unchanged. No migration path exists or is needed: at the time
20
+ // of the bump no provenance file existed anywhere with schema_version 1.
21
+ const PROVENANCE_SCHEMA_VERSION = 2
22
+
23
+ function emptyProvenance() {
24
+ return { schema_version: PROVENANCE_SCHEMA_VERSION, entries: {} }
25
+ }
26
+
27
+ // provenancePath returns the on-disk path of the provenance file, rooted at
28
+ // root. Mirrors internal/thirdparty/provenance.go:ProvenancePath.
29
+ function provenancePath(root) {
30
+ return path.join(root, '.trackfw', 'thirdparty-provenance.json')
31
+ }
32
+
33
+ // loadProvenance reads and validates the provenance file: a missing file is
34
+ // a legitimate "nothing installed from a third party yet" state and returns
35
+ // an empty, schema-valid Provenance; invalid JSON or an unsupported
36
+ // schema_version are fatal and thrown as errors — never silently degraded
37
+ // to empty (D8f). Deliberately asymmetric with readQuarantine (see
38
+ // quarantine.js) — do NOT "fix" this asymmetry, it is intentional. Mirrors
39
+ // internal/thirdparty/provenance.go:LoadProvenance.
40
+ function loadProvenance(root) {
41
+ const filename = provenancePath(root)
42
+ let data
43
+ try {
44
+ data = fs.readFileSync(filename, 'utf8')
45
+ } catch (err) {
46
+ if (err.code === 'ENOENT') return emptyProvenance()
47
+ throw new Error(`read thirdparty provenance: ${err.message}`)
48
+ }
49
+ let prov
50
+ try {
51
+ prov = JSON.parse(data)
52
+ } catch (err) {
53
+ throw new Error(`decode thirdparty provenance: ${err.message}`)
54
+ }
55
+ if (prov.schema_version !== PROVENANCE_SCHEMA_VERSION) {
56
+ throw new Error(`unsupported thirdparty provenance schema ${prov.schema_version}`)
57
+ }
58
+ if (!prov.entries || Array.isArray(prov.entries)) prov.entries = {}
59
+ return prov
60
+ }
61
+
62
+ // writeProvenance persists prov atomically.
63
+ //
64
+ // Failure here MUST propagate to the caller and abort the installation
65
+ // (D6) — the deliberate opposite of a best-effort/log-and-continue write.
66
+ // Provenance is the only record of who approved a third-party artifact, so
67
+ // losing a write silently would leave an unapproved artifact on disk
68
+ // indistinguishable from an approved one.
69
+ //
70
+ // Every entry's url is passed through redactURL before serialization
71
+ // (D6-bis) — defense-in-depth, mirroring
72
+ // internal/thirdparty/provenance.go:WriteProvenance's doc comment: no
73
+ // command in this codebase writes a provenance entry's url today (D10.2),
74
+ // but this call site guarantees the query string is never persisted here
75
+ // regardless. Idempotent: redacting an already-redacted url is a no-op.
76
+ function writeProvenance(root, prov) {
77
+ const entries = prov.entries || {}
78
+ const redactedEntries = {}
79
+ for (const [dest, entry] of Object.entries(entries)) {
80
+ redactedEntries[dest] = { ...entry, url: redactURL(entry.url) }
81
+ }
82
+ const toWrite = { schema_version: PROVENANCE_SCHEMA_VERSION, entries: redactedEntries }
83
+ const data = `${JSON.stringify(toWrite, null, 2)}\n`
84
+ atomicWrite(provenancePath(root), data, 0o600)
85
+ }
86
+
87
+ // upsertProvenanceEntry loads the current provenance, sets
88
+ // entries[dest] = entry, and writes it back. The write is fatal-on-failure
89
+ // (see writeProvenance) — callers MUST treat a thrown error as
90
+ // "installation aborted", never log-and-continue. Mirrors
91
+ // internal/thirdparty/provenance.go:UpsertProvenanceEntry.
92
+ function upsertProvenanceEntry(root, dest, entry) {
93
+ const prov = loadProvenance(root)
94
+ prov.entries[dest] = entry
95
+ writeProvenance(root, prov)
96
+ }
97
+
98
+ // verifyApproval is the D8c TOCTOU close: it only succeeds if dest has a
99
+ // provenance entry whose checksum_sha256 matches checksum exactly and whose
100
+ // approved_by is non-empty. A destination with no entry, an entry for a
101
+ // different checksum, or an entry with an empty approved_by all fail
102
+ // identically: not approved. Mirrors
103
+ // internal/thirdparty/provenance.go:VerifyApproval.
104
+ function verifyApproval(root, checksum, dest) {
105
+ const prov = loadProvenance(root)
106
+ const entry = prov.entries[dest]
107
+ if (!entry) throw new Error(`no provenance entry for destination "${dest}": not approved`)
108
+ if (entry.checksum_sha256 !== checksum) {
109
+ throw new Error(`provenance checksum mismatch for "${dest}": approved ${entry.checksum_sha256}, got ${checksum}`)
110
+ }
111
+ if (!entry.approved_by) throw new Error(`provenance entry for "${dest}" has no approved_by`)
112
+ }
113
+
114
+ module.exports = { provenancePath, loadProvenance, writeProvenance, upsertProvenanceEntry, verifyApproval, PROVENANCE_SCHEMA_VERSION }
@@ -0,0 +1,113 @@
1
+ 'use strict'
2
+
3
+ // Node port of internal/thirdparty/quarantine.go — the D8a/b on-disk
4
+ // quarantine record for one fetched third-party artifact. 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
+ // (D8a, D8b, D8f).
7
+
8
+ const crypto = require('node:crypto')
9
+ const fs = require('node:fs')
10
+ const path = require('node:path')
11
+
12
+ const { checksum: sha256hex, redactURL } = require('./markers')
13
+
14
+ // QUARANTINE_SCHEMA_VERSION mirrors internal/thirdparty/quarantine.go:quarantineSchemaVersion.
15
+ const QUARANTINE_SCHEMA_VERSION = 1
16
+
17
+ // atomicWrite writes data to filename via a temp file in the same
18
+ // directory followed by fs.renameSync, so a reader never observes a
19
+ // partially written file. Mirrors internal/thirdparty/quarantine.go's
20
+ // atomicWrite (shared there by quarantine.go and provenance.go; replicated
21
+ // here and in references.js, same rationale as the Go reference's own
22
+ // per-package duplication of Checksum in markers.go's doc comment).
23
+ function atomicWrite(filename, data, mode) {
24
+ const directory = path.dirname(filename)
25
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 })
26
+ const tmp = path.join(directory, `.trackfw-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
27
+ try {
28
+ fs.writeFileSync(tmp, data, { mode })
29
+ fs.chmodSync(tmp, mode)
30
+ fs.renameSync(tmp, filename)
31
+ } finally {
32
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
33
+ }
34
+ }
35
+
36
+ // quarantinePath returns the on-disk path of the quarantine record for
37
+ // checksum, rooted at root — the project or home directory the caller is
38
+ // operating on. Mirrors internal/thirdparty/quarantine.go:QuarantinePath.
39
+ function quarantinePath(root, checksum) {
40
+ return path.join(root, '.trackfw', 'thirdparty-quarantine', `${checksum}.json`)
41
+ }
42
+
43
+ // newQuarantineEntry builds a quarantine entry from freshly fetched content.
44
+ // matchedMarkers is checkMarkers()'s return value for raw; an empty array
45
+ // yields marker_check.result === "pass". The content is embedded whole,
46
+ // base64-encoded, in content_base64 — never a path to another file (D8b):
47
+ // an indirection through a second file would reopen the TOCTOU window the
48
+ // quarantine record exists to close.
49
+ //
50
+ // rawURL is stored via redactURL, not verbatim (D6-bis) — see
51
+ // internal/thirdparty/quarantine.go:NewQuarantineEntry's doc comment.
52
+ function newQuarantineEntry(rawURL, raw, matchedMarkers, kind, requestedTargets) {
53
+ const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw), 'utf8')
54
+ return {
55
+ schema_version: QUARANTINE_SCHEMA_VERSION,
56
+ url: redactURL(rawURL),
57
+ checksum_sha256: sha256hex(buf),
58
+ fetched_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
59
+ content_base64: buf.toString('base64'),
60
+ marker_check: {
61
+ result: matchedMarkers.length > 0 ? 'fail' : 'pass',
62
+ matched_markers: matchedMarkers,
63
+ },
64
+ kind,
65
+ requested_targets: requestedTargets || [],
66
+ }
67
+ }
68
+
69
+ // writeQuarantine persists entry atomically at
70
+ // quarantinePath(root, entry.checksum_sha256). Mirrors
71
+ // internal/thirdparty/quarantine.go:WriteQuarantine.
72
+ function writeQuarantine(root, entry) {
73
+ const toWrite = { ...entry, schema_version: QUARANTINE_SCHEMA_VERSION }
74
+ const data = `${JSON.stringify(toWrite, null, 2)}\n`
75
+ atomicWrite(quarantinePath(root, entry.checksum_sha256), data, 0o600)
76
+ }
77
+
78
+ // readQuarantine reads and validates the quarantine record for checksum,
79
+ // fail-closed (D8f): a missing file, invalid JSON, or an unsupported
80
+ // schema_version are all thrown as errors, never degraded to an empty
81
+ // value. Deliberately asymmetric with loadProvenance (see provenance.js):
82
+ // here, the caller already holds a checksum obtained from a prior fetch and
83
+ // is asking for that specific record, so its absence is itself the failure
84
+ // being guarded against. Do NOT "fix" this asymmetry — it is intentional,
85
+ // verified against the Go reference by audit. Mirrors
86
+ // internal/thirdparty/quarantine.go:ReadQuarantine.
87
+ function readQuarantine(root, checksum) {
88
+ const filename = quarantinePath(root, checksum)
89
+ let data
90
+ try {
91
+ data = fs.readFileSync(filename, 'utf8')
92
+ } catch (err) {
93
+ throw new Error(`read quarantine entry "${checksum}": ${err.message}`)
94
+ }
95
+ let entry
96
+ try {
97
+ entry = JSON.parse(data)
98
+ } catch (err) {
99
+ throw new Error(`decode quarantine entry "${checksum}": ${err.message}`)
100
+ }
101
+ if (entry.schema_version !== QUARANTINE_SCHEMA_VERSION) {
102
+ throw new Error(`unsupported quarantine schema ${entry.schema_version} for "${checksum}"`)
103
+ }
104
+ return entry
105
+ }
106
+
107
+ // decodeContent decodes entry.content_base64 back to the original raw
108
+ // bytes (a Buffer). Mirrors internal/thirdparty/quarantine.go:QuarantineEntry.DecodeContent.
109
+ function decodeContent(entry) {
110
+ return Buffer.from(entry.content_base64, 'base64')
111
+ }
112
+
113
+ module.exports = { quarantinePath, newQuarantineEntry, writeQuarantine, readQuarantine, decodeContent, atomicWrite, QUARANTINE_SCHEMA_VERSION }
@@ -0,0 +1,194 @@
1
+ 'use strict'
2
+
3
+ // Node port of the D9 extension point from internal/integrations/render.go
4
+ // (ThirdPartyReference, UpsertThirdPartyReference, ApplyThirdPartyReferences,
5
+ // NormalizeThirdPartyContent, ResolveThirdPartySkillDestination) — see the
6
+ // D9 amendment in
7
+ // 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.
8
+ //
9
+ // D9 note (see docs/agents-working-context.md, ML-1A entry): this file
10
+ // persists .trackfw/thirdparty-references.json, an artifact NOT mentioned
11
+ // by the original ADR text (D1-D8) — it exists because a plain rendered
12
+ // catalog agent file cannot simply have a reference block appended once:
13
+ // the next `trackfw agents update` calls buildPlans -> render again, which
14
+ // re-derives content straight from the catalog asset and knows nothing
15
+ // about the appended block. Persisting the reference here and having
16
+ // buildPlans call applyThirdPartyReferences after every render (see
17
+ // ../integrations/index.js) makes the canonical render reproduce the
18
+ // block, so repeated `agents update` runs settle at state "current"
19
+ // instead of fighting the third-party attachment.
20
+
21
+ const fs = require('node:fs')
22
+ const path = require('node:path')
23
+
24
+ const { atomicWrite } = require('./quarantine')
25
+ const { target: catalogTarget, surfaceFor } = require('../integrations/catalog')
26
+
27
+ const THIRDPARTY_REFERENCES_SCHEMA_VERSION = 1
28
+
29
+ // truncateBeforeIdSegment drops the "{{id}}"-bearing path segment and
30
+ // everything after it, returning the shared parent directory. Duplicated
31
+ // (not imported) from npm/src/integrations/catalog.js — that module does
32
+ // not export it, and this ML's file list does not include editing
33
+ // catalog.js; this is the same small, pure, low-risk duplication already
34
+ // used by the Go reference itself (see markers.go's doc comment on
35
+ // Checksum) rather than widening catalog.js's public surface for one
36
+ // caller.
37
+ function truncateBeforeIdSegment(p) {
38
+ const segments = p.split('/')
39
+ const index = segments.findIndex(segment => segment.includes('{{id}}'))
40
+ if (index === -1) return p
41
+ return segments.slice(0, index).join('/')
42
+ }
43
+
44
+ function thirdPartyReferencesPath(root) {
45
+ return path.join(root, '.trackfw', 'thirdparty-references.json')
46
+ }
47
+
48
+ function emptyRegistry() {
49
+ return { schema_version: THIRDPARTY_REFERENCES_SCHEMA_VERSION, entries: {} }
50
+ }
51
+
52
+ function loadThirdPartyReferenceRegistry(root) {
53
+ const filename = thirdPartyReferencesPath(root)
54
+ let data
55
+ try {
56
+ data = fs.readFileSync(filename, 'utf8')
57
+ } catch (err) {
58
+ if (err.code === 'ENOENT') return emptyRegistry()
59
+ throw new Error(`read thirdparty reference registry: ${err.message}`)
60
+ }
61
+ let reg
62
+ try {
63
+ reg = JSON.parse(data)
64
+ } catch (err) {
65
+ throw new Error(`decode thirdparty reference registry: ${err.message}`)
66
+ }
67
+ if (reg.schema_version !== THIRDPARTY_REFERENCES_SCHEMA_VERSION) {
68
+ throw new Error(`unsupported thirdparty reference registry schema ${reg.schema_version}`)
69
+ }
70
+ if (!reg.entries || Array.isArray(reg.entries)) reg.entries = {}
71
+ return reg
72
+ }
73
+
74
+ function writeThirdPartyReferenceRegistry(root, reg) {
75
+ const toWrite = { schema_version: THIRDPARTY_REFERENCES_SCHEMA_VERSION, entries: reg.entries || {} }
76
+ const data = `${JSON.stringify(toWrite, null, 2)}\n`
77
+ atomicWrite(thirdPartyReferencesPath(root), data, 0o600)
78
+ }
79
+
80
+ // upsertThirdPartyReference records (or replaces, keyed by ref.slug) a
81
+ // reference from the rendered catalog agent artifact (targetID,
82
+ // agentItemID) to a third-party skill, persisted under root (project root
83
+ // — D4). Idempotent: calling it twice with the same slug replaces the
84
+ // prior entry instead of duplicating it. Mirrors
85
+ // internal/integrations/render.go:UpsertThirdPartyReference.
86
+ function upsertThirdPartyReference(root, targetID, agentItemID, ref) {
87
+ const reg = loadThirdPartyReferenceRegistry(root)
88
+ const key = `${targetID}/${agentItemID}`
89
+ const refs = reg.entries[key] || []
90
+ let replaced = false
91
+ for (let i = 0; i < refs.length; i++) {
92
+ if (refs[i].slug === ref.slug) {
93
+ refs[i] = ref
94
+ replaced = true
95
+ break
96
+ }
97
+ }
98
+ if (!replaced) refs.push(ref)
99
+ refs.sort((a, b) => a.slug.localeCompare(b.slug))
100
+ reg.entries[key] = refs
101
+ writeThirdPartyReferenceRegistry(root, reg)
102
+ }
103
+
104
+ // THIRD_PARTY_REF_START/END are the composition markers (D5), dedicated and
105
+ // distinct from the auxiliary-rules-file markers (a different subsystem).
106
+ // Mirrors internal/integrations/render.go:thirdPartyRefStart/thirdPartyRefEnd.
107
+ const THIRD_PARTY_REF_START = '<!-- trackfw:thirdparty-skills:start -->'
108
+ const THIRD_PARTY_REF_END = '<!-- trackfw:thirdparty-skills:end -->'
109
+
110
+ // applyThirdPartyReferences injects or updates the third-party reference
111
+ // block in content for (targetID, agentItemID), based on entries persisted
112
+ // by upsertThirdPartyReference. When root is empty or there are no entries
113
+ // for this key, content is returned byte-for-byte unchanged — this is the
114
+ // guarantee that every agent artifact which never received a third-party
115
+ // attachment renders exactly as it did before D5/D9 were introduced.
116
+ // Mirrors internal/integrations/render.go:ApplyThirdPartyReferences.
117
+ function applyThirdPartyReferences(root, content, targetID, agentItemID) {
118
+ if (!root) return content
119
+ const reg = loadThirdPartyReferenceRegistry(root)
120
+ const refs = reg.entries[`${targetID}/${agentItemID}`] || []
121
+ if (!refs.length) return content
122
+
123
+ let block = `${THIRD_PARTY_REF_START}\n`
124
+ for (const ref of refs) {
125
+ block += `- Third-party skill "${ref.slug}": ${ref.destination} (source: ${ref.url})\n`
126
+ }
127
+ block += THIRD_PARTY_REF_END
128
+
129
+ let text = content
130
+ const start = text.indexOf(THIRD_PARTY_REF_START)
131
+ if (start === -1) {
132
+ if (!text.endsWith('\n')) text += '\n'
133
+ text += `\n${block}\n`
134
+ return text
135
+ }
136
+ // Search for the end marker starting at start, not from the beginning of
137
+ // text (hefesto-tf finding, ML-4C): searching the whole text could find
138
+ // an END marker that appears BEFORE start, producing end < start and
139
+ // silently corrupting the composed output. Anchoring the search at start
140
+ // makes that impossible: end is either -1 or >= start.
141
+ const relEnd = text.slice(start).indexOf(THIRD_PARTY_REF_END)
142
+ if (relEnd === -1) {
143
+ // Malformed (start without end): append a fresh block rather than
144
+ // guess at repair.
145
+ text += `\n${block}\n`
146
+ return text
147
+ }
148
+ const end = start + relEnd
149
+ const newText = text.slice(0, start) + block + text.slice(end + THIRD_PARTY_REF_END.length)
150
+ return `${newText.replace(/\n+$/, '')}\n`
151
+ }
152
+
153
+ // normalizeThirdPartyContent applies the same normalization used for
154
+ // managed catalog skill content (trim + single trailing newline) to raw
155
+ // third-party bytes before they are written through IntegrationManager, so
156
+ // third-party artifacts are stored with the same on-disk convention as
157
+ // catalog artifacts. Mirrors
158
+ // internal/integrations/render.go:NormalizeThirdPartyContent.
159
+ function normalizeThirdPartyContent(content) {
160
+ const text = Buffer.isBuffer(content) ? content.toString('utf8') : String(content)
161
+ return `${text.trim()}\n`
162
+ }
163
+
164
+ // resolveThirdPartySkillDestination computes where a third-party artifact's
165
+ // content should live for (targetID, scope), per D5: the shared parent
166
+ // directory of the target's own canonical project/global Skills install
167
+ // path template, followed by "/thirdparty/<slug>.md". The directory always
168
+ // comes from the catalog's own path template — never a hardcoded
169
+ // ".claude" — so every target the catalog declares a Skills capability for
170
+ // is supported without per-target special-casing. Mirrors
171
+ // internal/integrations/render.go:ResolveThirdPartySkillDestination.
172
+ function resolveThirdPartySkillDestination(targetID, scope, slug) {
173
+ let targetEntry
174
+ try {
175
+ targetEntry = catalogTarget(targetID)
176
+ } catch {
177
+ throw new Error(`unknown target "${targetID}"`)
178
+ }
179
+ const surface = surfaceFor(targetEntry, undefined, 'skills')
180
+ const installPath = (surface.paths.skills || []).find(entry => entry.scope === scope)
181
+ if (!installPath) throw new Error(`target ${targetID} surface ${surface.id} has no ${scope} skills path`)
182
+ const baseDir = truncateBeforeIdSegment(installPath.path)
183
+ return { destination: `${baseDir}/thirdparty/${slug}.md`, surfaceID: surface.id }
184
+ }
185
+
186
+ module.exports = {
187
+ thirdPartyReferencesPath,
188
+ upsertThirdPartyReference,
189
+ applyThirdPartyReferences,
190
+ normalizeThirdPartyContent,
191
+ resolveThirdPartySkillDestination,
192
+ THIRD_PARTY_REF_START,
193
+ THIRD_PARTY_REF_END,
194
+ }