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
|
@@ -115,22 +115,6 @@
|
|
|
115
115
|
"tail": "Number of recent transitions to show",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Manage trackfw plugins",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "List installed plugins",
|
|
122
|
-
"empty": "No plugins installed. Use `trackfw plugins add <user/repo>` to install one."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Install a plugin from GitHub Releases (user/repo or user/repo@tag)",
|
|
126
|
-
"installing": "Installing plugin from {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" installed successfully."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Remove an installed plugin",
|
|
131
|
-
"success": "Plugin \"{{name}}\" removed."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Show delivery metrics",
|
|
136
120
|
"no_data": "No transitions recorded yet.",
|
|
@@ -138,8 +122,6 @@
|
|
|
138
122
|
"export": "Export metrics to CSV file"
|
|
139
123
|
},
|
|
140
124
|
"errors": {
|
|
141
|
-
"notFound": "Not found: {{path}}"
|
|
142
|
-
"downloadFailed": "download failed: HTTP {{status}} for {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" not found"
|
|
125
|
+
"notFound": "Not found: {{path}}"
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -115,22 +115,6 @@
|
|
|
115
115
|
"tail": "Número de transiciones recientes a mostrar",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Gestionar plugins de trackfw",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "Listar plugins instalados",
|
|
122
|
-
"empty": "No hay plugins instalados. Usa `trackfw plugins add <user/repo>` para instalar uno."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Instalar un plugin desde GitHub Releases (user/repo o user/repo@tag)",
|
|
126
|
-
"installing": "Instalando plugin desde {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" instalado correctamente."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Eliminar un plugin instalado",
|
|
131
|
-
"success": "Plugin \"{{name}}\" eliminado."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Muestra métricas de entrega",
|
|
136
120
|
"no_data": "No hay transiciones registradas aún.",
|
|
@@ -138,8 +122,6 @@
|
|
|
138
122
|
"export": "Exportar métricas a archivo CSV"
|
|
139
123
|
},
|
|
140
124
|
"errors": {
|
|
141
|
-
"notFound": "No encontrado: {{path}}"
|
|
142
|
-
"downloadFailed": "fallo en la descarga: HTTP {{status}} para {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" no encontrado"
|
|
125
|
+
"notFound": "No encontrado: {{path}}"
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -115,22 +115,6 @@
|
|
|
115
115
|
"tail": "Número de transições recentes a exibir",
|
|
116
116
|
"header": "── trackfw log ─────────────────────────"
|
|
117
117
|
},
|
|
118
|
-
"plugins": {
|
|
119
|
-
"description": "Gerenciar plugins do trackfw",
|
|
120
|
-
"list": {
|
|
121
|
-
"description": "Listar plugins instalados",
|
|
122
|
-
"empty": "Nenhum plugin instalado. Use `trackfw plugins add <user/repo>` para instalar um."
|
|
123
|
-
},
|
|
124
|
-
"add": {
|
|
125
|
-
"description": "Instalar um plugin do GitHub Releases (user/repo ou user/repo@tag)",
|
|
126
|
-
"installing": "Instalando plugin de {{repo}}...",
|
|
127
|
-
"success": "Plugin \"{{name}}\" instalado com sucesso."
|
|
128
|
-
},
|
|
129
|
-
"remove": {
|
|
130
|
-
"description": "Remover um plugin instalado",
|
|
131
|
-
"success": "Plugin \"{{name}}\" removido."
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
118
|
"metrics": {
|
|
135
119
|
"description": "Exibe métricas de delivery",
|
|
136
120
|
"no_data": "Nenhuma transição registrada ainda.",
|
|
@@ -138,8 +122,6 @@
|
|
|
138
122
|
"export": "Exportar métricas para arquivo CSV"
|
|
139
123
|
},
|
|
140
124
|
"errors": {
|
|
141
|
-
"notFound": "Não encontrado: {{path}}"
|
|
142
|
-
"downloadFailed": "falha no download: HTTP {{status}} para {{url}}",
|
|
143
|
-
"pluginNotFound": "plugin \"{{name}}\" não encontrado"
|
|
125
|
+
"notFound": "Não encontrado: {{path}}"
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -8,6 +8,7 @@ const { IntegrationManager } = require('./manager')
|
|
|
8
8
|
const { legacyHashes } = require('./legacy')
|
|
9
9
|
const identityStore = require('../identity')
|
|
10
10
|
const { injectRulesForTool } = require('../generators/init')
|
|
11
|
+
const { applyThirdPartyReferences } = require('../thirdparty/references')
|
|
11
12
|
|
|
12
13
|
function parseSurfaces(values = []) {
|
|
13
14
|
const result = {}
|
|
@@ -55,7 +56,17 @@ function buildPlans(kind, options = {}) {
|
|
|
55
56
|
for (const item of selected.itemEntries) {
|
|
56
57
|
for (const installPath of paths) {
|
|
57
58
|
const destination = installPath.path.replace('{{id}}', item.id)
|
|
58
|
-
|
|
59
|
+
let content = render({ target: targetEntry.id, kind, item, content: readAsset(item), capability, destination, identity: identityConfig })
|
|
60
|
+
// D5/D9 extension point: reproduce any persisted third-party
|
|
61
|
+
// reference block so regenerating this exact artifact (e.g. a
|
|
62
|
+
// later `trackfw agents update`) settles at state "current"
|
|
63
|
+
// instead of treating the attachment as drift. See the doc
|
|
64
|
+
// comment on applyThirdPartyReferences (../thirdparty/references.js)
|
|
65
|
+
// for why this cannot live inside render() itself. Mirrors
|
|
66
|
+
// internal/integrations/plan.go:BuildPlans.
|
|
67
|
+
if (kind === 'agents' && options.projectRoot) {
|
|
68
|
+
content = applyThirdPartyReferences(options.projectRoot, content, targetEntry.id, item.id)
|
|
69
|
+
}
|
|
59
70
|
const claim = { target: targetEntry.id, surface: surface.id, scope, kind, item: item.id }
|
|
60
71
|
plans.push({
|
|
61
72
|
claim,
|
|
@@ -100,8 +111,14 @@ function execute(kind, operation, options = {}, roots = {}) {
|
|
|
100
111
|
// executeIntegrationMutation/executeIntegrationList).
|
|
101
112
|
const homeRoot = roots.homeRoot || os.homedir()
|
|
102
113
|
const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
|
|
103
|
-
const plans = buildPlans(kind, { ...options, identity: identityConfig })
|
|
104
114
|
const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
|
|
115
|
+
// D5/D9: manager.roots.project lets buildPlans reproduce any persisted
|
|
116
|
+
// third-party reference block (see the projectRoot doc comment above the
|
|
117
|
+
// applyThirdPartyReferences call in buildPlans) so a plain `agents update`
|
|
118
|
+
// settles at state "current" after a third-party attachment instead of
|
|
119
|
+
// reporting drift. Mirrors internal/commands/integrations_flags.go passing
|
|
120
|
+
// manager.ProjectRoot to BuildPlans at both call sites.
|
|
121
|
+
const plans = buildPlans(kind, { ...options, identity: identityConfig, projectRoot: options.projectRoot || manager.roots.project })
|
|
105
122
|
let statuses
|
|
106
123
|
if (operation === 'list') statuses = manager.inspect(plans)
|
|
107
124
|
else if (operation === 'install') statuses = manager.install(plans, { force: options.force })
|
|
@@ -10,8 +10,21 @@ const { tildeify } = require('../lib/update-engine')
|
|
|
10
10
|
|
|
11
11
|
const SCHEMA_VERSION = 1
|
|
12
12
|
const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// claimKey includes origin (ADR-2026-08-15 D11) so a catalog claim and a
|
|
14
|
+
// third-party claim that happen to share target/surface/scope/kind/item are
|
|
15
|
+
// never treated as the same ownership record — mirrors Go's Claim struct
|
|
16
|
+
// equality (`==`), field-by-field, which already includes Origin
|
|
17
|
+
// (internal/integrations/manager.go's claimOwned/appendClaim/removeClaim).
|
|
18
|
+
const claimKey = claim => [claim.target, claim.surface, claim.scope, claim.kind, claim.item, claim.origin || ''].join('\u0000')
|
|
19
|
+
// cleanClaim omits origin entirely when falsy/absent, mirroring Go's
|
|
20
|
+
// `json:"origin,omitempty"` — a catalog claim (origin "") must serialize
|
|
21
|
+
// with NO "origin" key at all, byte-identical to a manifest written before
|
|
22
|
+
// this field existed (retrocompatibility, D11).
|
|
23
|
+
const cleanClaim = claim => {
|
|
24
|
+
const out = { target: claim.target, surface: claim.surface, scope: claim.scope, kind: claim.kind, item: claim.item }
|
|
25
|
+
if (claim.origin) out.origin = claim.origin
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
15
28
|
|
|
16
29
|
class IntegrationManager {
|
|
17
30
|
constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
|
|
@@ -67,6 +67,92 @@ function resolveModel(model) {
|
|
|
67
67
|
return MODEL_MAP[model] || ''
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Mapa de model: nomes canônicos do catálogo → ID de modelo aceito pelo Codex
|
|
71
|
+
// CLI. Fonte: documentação Codex CLI pesquisada em 2026-08-14 (ver ADR
|
|
72
|
+
// ADR-2026-08-14-roteamento-de-model-tier-por-alvo-no-render-de-agentes-para-
|
|
73
|
+
// codex-e-cursor). IDs de modelo Codex são versionados e mudam com o ciclo de
|
|
74
|
+
// release da OpenAI.
|
|
75
|
+
const CODEX_MODEL_MAP = { opus: 'gpt-5.4', sonnet: 'gpt-5.4-mini' }
|
|
76
|
+
|
|
77
|
+
// resolveModelCodex converte o modelo canônico para o ID de modelo aceito
|
|
78
|
+
// pelo Codex CLI. Retorna o valor mapeado, ou string vazia se a linha model
|
|
79
|
+
// deve ser omitida (valor desconhecido ou ausente). Espelha
|
|
80
|
+
// internal/integrations/render.go:mapModelCodex.
|
|
81
|
+
function resolveModelCodex(model) {
|
|
82
|
+
if (!model) return ''
|
|
83
|
+
return CODEX_MODEL_MAP[model] || ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Mapa de model: nomes canônicos do catálogo → valor aceito pela Cursor
|
|
87
|
+
// (fonte: cursor.com/docs/subagents, ver ADR ADR-2026-08-14-roteamento-de-
|
|
88
|
+
// model-tier-por-alvo-no-render-de-agentes-para-codex-e-cursor).
|
|
89
|
+
const CURSOR_MODEL_MAP = { opus: 'claude-opus-5[effort=high]', sonnet: 'composer-2.5[fast=true]' }
|
|
90
|
+
|
|
91
|
+
// mapModelCursor converte o modelo canônico para o valor aceito pela Cursor.
|
|
92
|
+
// Retorna o valor mapeado, ou string vazia se a linha "model:" deve ser
|
|
93
|
+
// removida do frontmatter (Cursor cai no default "inherit"/Auto). Espelha
|
|
94
|
+
// internal/integrations/render.go:mapModelCursor.
|
|
95
|
+
function mapModelCursor(model) {
|
|
96
|
+
if (!model) return ''
|
|
97
|
+
return CURSOR_MODEL_MAP[model] || ''
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// rewriteFrontmatterModelLine substitui a linha "model:" do frontmatter de um
|
|
101
|
+
// markdown cru por value, preservando toda outra linha do frontmatter e o
|
|
102
|
+
// corpo byte a byte. Se o frontmatter não tiver linha "model:", uma é
|
|
103
|
+
// anexada como última linha do bloco de frontmatter. Se source não tiver
|
|
104
|
+
// frontmatter reconhecível, source é retornado sem alteração (trimado) —
|
|
105
|
+
// espelha a detecção de fronteira usada por rewriteFrontmatterFields,
|
|
106
|
+
// escopada à chave única "model". Espelha
|
|
107
|
+
// internal/integrations/render.go:rewriteFrontmatterModelLine.
|
|
108
|
+
function rewriteFrontmatterModelLine(source, value) {
|
|
109
|
+
const trimmed = String(source).trim()
|
|
110
|
+
if (!trimmed.startsWith('---\n')) return trimmed
|
|
111
|
+
const end = trimmed.indexOf('\n---', 4)
|
|
112
|
+
if (end < 0) return trimmed
|
|
113
|
+
const frontmatter = trimmed.slice(4, end)
|
|
114
|
+
const rest = trimmed.slice(end) // começa com "\n---", seguido do corpo
|
|
115
|
+
|
|
116
|
+
const lines = frontmatter.split('\n')
|
|
117
|
+
let found = false
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
const line = lines[i]
|
|
120
|
+
const separator = line.indexOf(':')
|
|
121
|
+
if (separator < 0 || line.slice(0, separator).trim() !== 'model') continue
|
|
122
|
+
const rawValue = line.slice(separator + 1).trim()
|
|
123
|
+
const quoted = rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"')
|
|
124
|
+
lines[i] = quoted ? `model: "${value}"` : `model: ${value}`
|
|
125
|
+
found = true
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
if (!found) lines.push(`model: ${value}`)
|
|
129
|
+
|
|
130
|
+
return `---\n${lines.join('\n')}${rest}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// removeFrontmatterModelLine remove a linha "model:" do frontmatter de um
|
|
134
|
+
// markdown cru, se presente, preservando toda outra linha do frontmatter e o
|
|
135
|
+
// corpo byte a byte. Se source não tiver linha "model:" ou frontmatter
|
|
136
|
+
// reconhecível, source é retornado sem alteração (trimado). Espelha
|
|
137
|
+
// internal/integrations/render.go:removeFrontmatterModelLine.
|
|
138
|
+
function removeFrontmatterModelLine(source) {
|
|
139
|
+
const trimmed = String(source).trim()
|
|
140
|
+
if (!trimmed.startsWith('---\n')) return trimmed
|
|
141
|
+
const end = trimmed.indexOf('\n---', 4)
|
|
142
|
+
if (end < 0) return trimmed
|
|
143
|
+
const frontmatter = trimmed.slice(4, end)
|
|
144
|
+
const rest = trimmed.slice(end) // começa com "\n---", seguido do corpo
|
|
145
|
+
|
|
146
|
+
const lines = frontmatter.split('\n')
|
|
147
|
+
const kept = lines.filter(line => {
|
|
148
|
+
const separator = line.indexOf(':')
|
|
149
|
+
return !(separator >= 0 && line.slice(0, separator).trim() === 'model')
|
|
150
|
+
})
|
|
151
|
+
if (kept.length === lines.length) return trimmed
|
|
152
|
+
|
|
153
|
+
return `---\n${kept.join('\n')}${rest}`
|
|
154
|
+
}
|
|
155
|
+
|
|
70
156
|
// toolsFor retorna SET_ARCH para o agente canônico "architect" (item.id do
|
|
71
157
|
// catálogo, não o nome renderizado — que pode ser customizado pela
|
|
72
158
|
// identidade), SET_IMPL para os demais. IDs proibidos (edit_file, read_file,
|
|
@@ -235,12 +321,13 @@ function frontmatterName(content) {
|
|
|
235
321
|
// normalize(content) — a mesma expressão usada antes de existir suporte a
|
|
236
322
|
// identidade — então a saída sem identidade é garantida byte a byte
|
|
237
323
|
// inalterada por construção, não por coincidência.
|
|
238
|
-
function render({ kind, content, capability, item, identity: cfg }) {
|
|
324
|
+
function render({ kind, content, capability, item, identity: cfg, target }) {
|
|
239
325
|
if (kind === 'skills') return normalize(content)
|
|
240
326
|
|
|
241
327
|
const id = item && cfg ? lookupIdentity(cfg, item.id) : undefined
|
|
242
328
|
const parts = markdownParts(content)
|
|
243
329
|
let { name, description, body } = parts
|
|
330
|
+
let source = content
|
|
244
331
|
let greeting = ''
|
|
245
332
|
if (id) {
|
|
246
333
|
greeting = greetingLine(id.display_name, (cfg && cfg.user_nickname) || '')
|
|
@@ -250,7 +337,14 @@ function render({ kind, content, capability, item, identity: cfg }) {
|
|
|
250
337
|
}
|
|
251
338
|
|
|
252
339
|
if (capability.representation === 'custom-agent-toml') {
|
|
253
|
-
|
|
340
|
+
const lines = [
|
|
341
|
+
`name = ${JSON.stringify(name.replaceAll('-', '_'))}`,
|
|
342
|
+
`description = ${JSON.stringify(description)}`,
|
|
343
|
+
]
|
|
344
|
+
const mappedModel = resolveModelCodex(parts.model)
|
|
345
|
+
if (mappedModel) lines.push(`model = ${JSON.stringify(mappedModel)}`)
|
|
346
|
+
lines.push(`developer_instructions = ${JSON.stringify(body)}`)
|
|
347
|
+
return `${lines.join('\n')}\n`
|
|
254
348
|
}
|
|
255
349
|
if (capability.representation === 'cli-agent-json' || capability.representation === 'agent-json') {
|
|
256
350
|
// Ordem alfabética das chaves (description, name, prompt) é obrigatória:
|
|
@@ -310,8 +404,12 @@ function render({ kind, content, capability, item, identity: cfg }) {
|
|
|
310
404
|
return out
|
|
311
405
|
}
|
|
312
406
|
|
|
313
|
-
if (
|
|
314
|
-
|
|
407
|
+
if (target === 'cursor') {
|
|
408
|
+
const mappedModel = mapModelCursor(parts.model)
|
|
409
|
+
source = mappedModel ? rewriteFrontmatterModelLine(source, mappedModel) : removeFrontmatterModelLine(source)
|
|
410
|
+
}
|
|
411
|
+
if (!id) return normalize(source)
|
|
412
|
+
const withBody = insertBodyPrefix(source, greeting)
|
|
315
413
|
const withFrontmatter = rewriteFrontmatterFields(withBody, name, description)
|
|
316
414
|
const withSignature = rewriteSignatureLine(withFrontmatter, id.display_name)
|
|
317
415
|
return normalize(withSignature)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// unknown-command.js — canonical cross-CLI "unknown command" message and the
|
|
4
|
+
// shared suggestion algorithm.
|
|
5
|
+
//
|
|
6
|
+
// ADR-2026-08-15-remocao-do-subsistema-de-plugins-em-vez-de-gate-de-binario-de-
|
|
7
|
+
// terceiro.md (D3): argument dispatch to an external `trackfw-<name>` binary was
|
|
8
|
+
// removed together with the plugin subsystem. An unrecognized top-level command
|
|
9
|
+
// now always produces the message below instead — in all three CLIs.
|
|
10
|
+
//
|
|
11
|
+
// The plain (no-transposition) Levenshtein distance and the suggestion-picking
|
|
12
|
+
// rule here are reimplemented IDENTICALLY in Go (internal/commands/root.go,
|
|
13
|
+
// suggestCommand/levenshteinDistance) and Python (pypi/trackfw/cli.py,
|
|
14
|
+
// _suggest_command/_levenshtein_distance) — deliberately NOT delegated to each
|
|
15
|
+
// framework's own suggestion helper (cobra's Command.SuggestionsFor, commander's
|
|
16
|
+
// suggestSimilar), which use different distance functions/thresholds and would
|
|
17
|
+
// make the three CLIs disagree on whether/what to suggest for the same typo.
|
|
18
|
+
// Parity is enforced by scripts/check-unknown-command-parity.sh.
|
|
19
|
+
|
|
20
|
+
function levenshteinDistance(a, b) {
|
|
21
|
+
const la = a.length
|
|
22
|
+
const lb = b.length
|
|
23
|
+
const d = []
|
|
24
|
+
for (let i = 0; i <= la; i++) {
|
|
25
|
+
d.push(new Array(lb + 1).fill(0))
|
|
26
|
+
d[i][0] = i
|
|
27
|
+
}
|
|
28
|
+
for (let j = 0; j <= lb; j++) {
|
|
29
|
+
d[0][j] = j
|
|
30
|
+
}
|
|
31
|
+
for (let i = 1; i <= la; i++) {
|
|
32
|
+
for (let j = 1; j <= lb; j++) {
|
|
33
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
34
|
+
d[i][j] = Math.min(
|
|
35
|
+
d[i - 1][j] + 1, // deletion
|
|
36
|
+
d[i][j - 1] + 1, // insertion
|
|
37
|
+
d[i - 1][j - 1] + cost, // substitution
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return d[la][lb]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// suggestCommand — a candidate is eligible when its case-insensitive
|
|
45
|
+
// Levenshtein distance to `typed` is <= 2, OR it is a case-insensitive prefix
|
|
46
|
+
// match (candidate starts with the typed text). Among eligible candidates the
|
|
47
|
+
// winner is the one with the lowest distance, alphabetical tie-break, so the
|
|
48
|
+
// pick is deterministic and single — matching Go/Python exactly.
|
|
49
|
+
function suggestCommand(typed, candidates) {
|
|
50
|
+
const lowerTyped = typed.toLowerCase()
|
|
51
|
+
let bestDist = -1
|
|
52
|
+
let best = ''
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
const lowerC = candidate.toLowerCase()
|
|
55
|
+
const dist = levenshteinDistance(lowerTyped, lowerC)
|
|
56
|
+
const prefixMatch = lowerTyped.length > 0 && lowerC.startsWith(lowerTyped)
|
|
57
|
+
if (dist > 2 && !prefixMatch) continue
|
|
58
|
+
if (bestDist === -1 || dist < bestDist || (dist === bestDist && candidate < best)) {
|
|
59
|
+
bestDist = dist
|
|
60
|
+
best = candidate
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return bestDist === -1 ? null : best
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// formatUnknownCommandError — canonical message, byte-identical across the
|
|
67
|
+
// three CLIs modulo the typed name and the suggestion (present only when
|
|
68
|
+
// suggestCommand finds an eligible candidate):
|
|
69
|
+
// Error: unknown command "x" for "trackfw"
|
|
70
|
+
// Did you mean "validate"?
|
|
71
|
+
// Run 'trackfw --help' for usage.
|
|
72
|
+
function formatUnknownCommandError(typed, candidates, cmdPath) {
|
|
73
|
+
const lines = [`Error: unknown command "${typed}" for "${cmdPath}"`]
|
|
74
|
+
const suggestion = suggestCommand(typed, candidates)
|
|
75
|
+
if (suggestion) {
|
|
76
|
+
lines.push(`Did you mean "${suggestion}"?`)
|
|
77
|
+
}
|
|
78
|
+
lines.push(`Run '${cmdPath} --help' for usage.`)
|
|
79
|
+
return lines.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { levenshteinDistance, suggestCommand, formatUnknownCommandError }
|