@nifrajs/cli 5.0.0 → 5.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.
- package/README.md +7 -2
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +4 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +24 -7
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +2 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +8 -2
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +43 -10
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +39 -33
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +106 -48
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +5 -3
- package/dist/client-module.d.ts.map +1 -1
- package/dist/client-module.js +34 -10
- package/dist/client-module.js.map +1 -1
- package/dist/command-catalog.d.ts +9 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +67 -22
- package/dist/command-catalog.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +93 -12
- package/dist/fix-recipes.js.map +1 -1
- package/dist/init-agents.d.ts +16 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +3 -2
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +14 -1
- package/dist/internal/project-specifiers.d.ts.map +1 -1
- package/dist/internal/project-specifiers.js +54 -15
- package/dist/internal/project-specifiers.js.map +1 -1
- package/dist/internal/source-facts.d.ts +6 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +9 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +32 -1
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +5 -8
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +2 -2
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +23 -3
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +6 -21
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +8 -3
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +13 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +23 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +40 -17
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +40 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1316 -228
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +34 -0
- package/dist/output-schemas.d.ts.map +1 -1
- package/dist/output-schemas.js +132 -21
- package/dist/output-schemas.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/release-verification.d.ts +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +5 -0
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +3 -1
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +3 -1
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +6 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +37 -0
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +20 -1
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +29 -3
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +236 -22
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/security.d.ts +3 -2
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +42 -11
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +31 -9
- package/dist/rules/zones.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +127 -17
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +3 -9
- package/dist/test-tool.js.map +1 -1
- package/dist/upgrade.d.ts +20 -5
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +86 -9
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +5 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts.map +1 -1
- package/dist/version-drift.js +55 -3
- package/dist/version-drift.js.map +1 -1
- package/docs/examples.json +2 -2
- package/docs/llms-full.txt +481 -61
- package/docs/types.json +147 -70
- package/package.json +11 -11
- package/src/assure-hydration.ts +4 -1
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +33 -7
- package/src/check-diagnostics.ts +2 -0
- package/src/check-scan.ts +56 -9
- package/src/check.ts +55 -39
- package/src/cli.ts +104 -35
- package/src/client-module.ts +45 -9
- package/src/command-catalog.ts +77 -21
- package/src/dev-feed-tool.ts +112 -1
- package/src/fix-recipes.ts +101 -10
- package/src/init-agents.ts +4 -3
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/project-specifiers.ts +69 -16
- package/src/internal/source-facts.ts +23 -5
- package/src/internal/typescript-import.ts +5 -3
- package/src/introspect.ts +39 -1
- package/src/levels-tool.ts +7 -9
- package/src/load.ts +29 -3
- package/src/mcp-context.ts +5 -18
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +9 -2
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +33 -0
- package/src/mcp-path.ts +8 -22
- package/src/mcp.ts +48 -19
- package/src/migrate-layout.ts +1496 -237
- package/src/output-schemas.ts +179 -23
- package/src/project-path.ts +44 -13
- package/src/release-verification.ts +20 -0
- package/src/review.ts +5 -0
- package/src/rules/codes.ts +3 -1
- package/src/rules/data-guard.ts +40 -0
- package/src/rules/legacy.ts +22 -1
- package/src/rules/pages.ts +266 -24
- package/src/rules/security.ts +49 -11
- package/src/rules/zones.ts +30 -7
- package/src/stylex-migrate.ts +117 -17
- package/src/test-tool.ts +3 -9
- package/src/upgrade.ts +96 -8
- package/src/verification-plan.ts +9 -2
- package/src/version-drift.ts +53 -2
package/src/stylex-migrate.ts
CHANGED
|
@@ -1099,27 +1099,126 @@ const existingStylexImport = (source: string): string | undefined => {
|
|
|
1099
1099
|
return match?.[1]
|
|
1100
1100
|
}
|
|
1101
1101
|
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const
|
|
1110
|
-
if (
|
|
1111
|
-
|
|
1112
|
-
|
|
1102
|
+
const isIdentifierChar = (char: string | undefined): boolean =>
|
|
1103
|
+
char !== undefined && /[\w$]/.test(char)
|
|
1104
|
+
|
|
1105
|
+
/** Past the whitespace and comments at `index`. `inLine` stops at the end of the line. */
|
|
1106
|
+
function skipTrivia(source: string, index: number, inLine = false): number {
|
|
1107
|
+
let at = index
|
|
1108
|
+
while (at < source.length) {
|
|
1109
|
+
const char = source[at]
|
|
1110
|
+
if (char === " " || char === "\t" || char === "\r" || (char === "\n" && !inLine)) at++
|
|
1111
|
+
else if (source.startsWith("//", at)) {
|
|
1112
|
+
const end = source.indexOf("\n", at)
|
|
1113
|
+
if (end === -1) return source.length
|
|
1114
|
+
at = inLine ? end : end + 1
|
|
1115
|
+
} else if (source.startsWith("/*", at)) {
|
|
1116
|
+
const end = source.indexOf("*/", at + 2)
|
|
1117
|
+
if (end === -1) return source.length
|
|
1118
|
+
at = end + 2
|
|
1119
|
+
} else break
|
|
1120
|
+
}
|
|
1121
|
+
return at
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function skipString(source: string, index: number): number {
|
|
1125
|
+
const quote = source[index]
|
|
1126
|
+
let at = index + 1
|
|
1127
|
+
while (at < source.length && source[at] !== quote && source[at] !== "\n")
|
|
1128
|
+
at += source[at] === "\\" ? 2 : 1
|
|
1129
|
+
return Math.min(at + 1, source.length)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Past an import's specifier: its `with { ... }` (or `assert`) attributes and a `;`. */
|
|
1133
|
+
function afterSpecifier(source: string, index: number): number {
|
|
1134
|
+
let at = index
|
|
1135
|
+
const next = skipTrivia(source, at, true)
|
|
1136
|
+
for (const keyword of ["with", "assert"]) {
|
|
1137
|
+
if (!source.startsWith(keyword, next) || isIdentifierChar(source[next + keyword.length]))
|
|
1138
|
+
continue
|
|
1139
|
+
const open = skipTrivia(source, next + keyword.length)
|
|
1140
|
+
const close = source[open] === "{" ? source.indexOf("}", open) : -1
|
|
1141
|
+
if (close !== -1) at = close + 1
|
|
1142
|
+
}
|
|
1143
|
+
const semicolon = skipTrivia(source, at, true)
|
|
1144
|
+
return source[semicolon] === ";" ? semicolon + 1 : at
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** Where the import declaration whose `import` keyword is at `index` ends, or `undefined` for an
|
|
1148
|
+
* `import(...)` / `import.meta` expression or a declaration this scan cannot read. */
|
|
1149
|
+
function importEnd(source: string, index: number): number | undefined {
|
|
1150
|
+
let at = skipTrivia(source, index + "import".length)
|
|
1151
|
+
if (source[at] === "(" || source[at] === ".") return undefined
|
|
1152
|
+
if (source[at] === '"' || source[at] === "'")
|
|
1153
|
+
return afterSpecifier(source, skipString(source, at))
|
|
1154
|
+
let depth = 0
|
|
1155
|
+
while (at < source.length) {
|
|
1156
|
+
const char = source[at]
|
|
1157
|
+
if (char === '"' || char === "'") {
|
|
1158
|
+
at = skipString(source, at)
|
|
1113
1159
|
continue
|
|
1114
1160
|
}
|
|
1115
|
-
if (
|
|
1116
|
-
|
|
1161
|
+
if (source.startsWith("//", at) || source.startsWith("/*", at)) {
|
|
1162
|
+
at = skipTrivia(source, at)
|
|
1117
1163
|
continue
|
|
1118
1164
|
}
|
|
1119
|
-
|
|
1165
|
+
if (char === "{") depth++
|
|
1166
|
+
else if (char === "}") depth--
|
|
1167
|
+
else if (depth === 0 && (char === ";" || char === "=")) return undefined
|
|
1168
|
+
else if (
|
|
1169
|
+
depth === 0 &&
|
|
1170
|
+
source.startsWith("from", at) &&
|
|
1171
|
+
!isIdentifierChar(source[at - 1]) &&
|
|
1172
|
+
!isIdentifierChar(source[at + 4])
|
|
1173
|
+
) {
|
|
1174
|
+
const specifier = skipTrivia(source, at + 4)
|
|
1175
|
+
if (source[specifier] === '"' || source[specifier] === "'")
|
|
1176
|
+
return afterSpecifier(source, skipString(source, specifier))
|
|
1177
|
+
}
|
|
1178
|
+
at++
|
|
1179
|
+
}
|
|
1180
|
+
return undefined
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Past the rest of the line `index` is on when only trivia is left on it, then any blank lines. */
|
|
1184
|
+
function nextLine(source: string, index: number): number {
|
|
1185
|
+
let at = skipTrivia(source, index, true)
|
|
1186
|
+
if (at < source.length && source[at] !== "\n") return index
|
|
1187
|
+
for (;;) {
|
|
1188
|
+
if (source[at] !== "\n") return at
|
|
1189
|
+
let next = at + 1
|
|
1190
|
+
while (source[next] === " " || source[next] === "\t" || source[next] === "\r") next++
|
|
1191
|
+
if (next < source.length && source[next] !== "\n") return at + 1
|
|
1192
|
+
at = next
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Where the generated block goes: after the directive prologue and the import declarations. Comments
|
|
1198
|
+
* are read as comments, so one before `"use client"` keeps the directive first, and a trailing
|
|
1199
|
+
* `// note` on the last import does not hide where it ends.
|
|
1200
|
+
*/
|
|
1201
|
+
const insertionOffset = (source: string): number => {
|
|
1202
|
+
let end = source.startsWith("#!") ? source.indexOf("\n") + 1 || source.length : 0
|
|
1203
|
+
let prologue = true
|
|
1204
|
+
for (;;) {
|
|
1205
|
+
const at = skipTrivia(source, end)
|
|
1206
|
+
const char = source[at]
|
|
1207
|
+
let statementEnd: number | undefined
|
|
1208
|
+
if (prologue && (char === '"' || char === "'")) {
|
|
1209
|
+
const literal = skipString(source, at)
|
|
1210
|
+
const after = skipTrivia(source, literal, true)
|
|
1211
|
+
// A string with more of an expression after it on its line is not a directive.
|
|
1212
|
+
if (source[after] === ";") statementEnd = after + 1
|
|
1213
|
+
else if (after === source.length || source[after] === "\n") statementEnd = literal
|
|
1214
|
+
} else if (source.startsWith("import", at) && !isIdentifierChar(source[at + 6])) {
|
|
1215
|
+
prologue = false
|
|
1216
|
+
statementEnd = importEnd(source, at)
|
|
1217
|
+
}
|
|
1218
|
+
if (statementEnd === undefined) break
|
|
1219
|
+
end = nextLine(source, statementEnd)
|
|
1120
1220
|
}
|
|
1121
|
-
|
|
1122
|
-
return offset
|
|
1221
|
+
return end
|
|
1123
1222
|
}
|
|
1124
1223
|
|
|
1125
1224
|
/** Transform one source file. The returned source is unchanged when every match is unsupported. */
|
|
@@ -1196,7 +1295,8 @@ export function migrateTailwindSource(source: string, file: string): SourceMigra
|
|
|
1196
1295
|
const needsImport = existingStylexImport(source) === undefined
|
|
1197
1296
|
const generated = `${needsImport ? `import * as ${stylexName} from "${STYLEX_SPECIFIER}"\n\n` : ""}const ${stylesName} = ${stylexName}.create(${JSON.stringify(Object.fromEntries(styles), null, 2)})\n\n`
|
|
1198
1297
|
const offset = insertionOffset(rewritten)
|
|
1199
|
-
const
|
|
1298
|
+
const separator = offset > 0 && rewritten[offset - 1] !== "\n" ? "\n" : ""
|
|
1299
|
+
const withGenerated = `${rewritten.slice(0, offset)}${separator}${generated}${rewritten.slice(offset)}`
|
|
1200
1300
|
return { source: withGenerated, changed: true, replacements: replacements.length, issues }
|
|
1201
1301
|
}
|
|
1202
1302
|
|
package/src/test-tool.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
escalatingStop,
|
|
2
3
|
type HeadTailOutput,
|
|
3
4
|
OWN_PROCESS_GROUP,
|
|
4
5
|
readHeadTail,
|
|
@@ -63,8 +64,6 @@ function normalizeTimeout(value: unknown): number {
|
|
|
63
64
|
return Math.min(Math.max(Math.trunc(value), 1_000), MAX_TIMEOUT_MS)
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const KILL_GRACE_MS = 2_000
|
|
67
|
-
|
|
68
67
|
const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g")
|
|
69
68
|
|
|
70
69
|
// A UTF-8 character is at most four bytes, so these hold every character the result can show.
|
|
@@ -157,12 +156,7 @@ export async function collectTestResult(
|
|
|
157
156
|
})
|
|
158
157
|
let timedOut = false
|
|
159
158
|
let cancelled = false
|
|
160
|
-
|
|
161
|
-
// A test can ignore SIGTERM, so the group gets SIGKILL once the grace period runs out.
|
|
162
|
-
const stop = (): void => {
|
|
163
|
-
signalProcessGroup(proc, "SIGTERM")
|
|
164
|
-
escalation ??= setTimeout(() => signalProcessGroup(proc, "SIGKILL"), KILL_GRACE_MS)
|
|
165
|
-
}
|
|
159
|
+
const { stop, dispose } = escalatingStop((name) => signalProcessGroup(proc, name))
|
|
166
160
|
const abort = (): void => {
|
|
167
161
|
cancelled = true
|
|
168
162
|
stop()
|
|
@@ -189,7 +183,7 @@ export async function collectTestResult(
|
|
|
189
183
|
exitCode = result[2]
|
|
190
184
|
} finally {
|
|
191
185
|
clearTimeout(timer)
|
|
192
|
-
|
|
186
|
+
dispose()
|
|
193
187
|
opts.signal?.removeEventListener("abort", abort)
|
|
194
188
|
}
|
|
195
189
|
const stdout = clean(stdoutRaw)
|
package/src/upgrade.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 1. **pin sweep** - set every matching dependency to the target version across the workspace's
|
|
7
7
|
* package.json files, preserving the caret/tilde/exact style and skipping `workspace:`/`link:` specs.
|
|
8
|
-
* `overrides`, `resolutions` and `pnpm.overrides` are swept too, since they decide what installs
|
|
9
|
-
*
|
|
8
|
+
* `overrides`, `resolutions` and `pnpm.overrides` are swept too, since they decide what installs.
|
|
9
|
+
* The MCP launch pin in `.mcp.json`, `.cursor/mcp.json`, CLAUDE.md and AGENTS.md moves too, the
|
|
10
|
+
* edit `nifra init-agents --sync-mcp` makes; a version pinned anywhere else (a Dockerfile, a CI
|
|
11
|
+
* workflow, another lockfile) is listed only.
|
|
10
12
|
* 2. **dependency moves** - replace removed packages with their supported successor without
|
|
11
13
|
* reserializing package.json or leaving duplicate dependency keys.
|
|
12
14
|
* 3. **import moves** - rewrite exact import specifiers to their updated module paths.
|
|
@@ -26,6 +28,7 @@
|
|
|
26
28
|
|
|
27
29
|
import {
|
|
28
30
|
existsSync,
|
|
31
|
+
lstatSync,
|
|
29
32
|
mkdirSync,
|
|
30
33
|
readdirSync,
|
|
31
34
|
readFileSync,
|
|
@@ -45,6 +48,7 @@ import {
|
|
|
45
48
|
parseHookReport,
|
|
46
49
|
renderHookDiff,
|
|
47
50
|
} from "./hooks-report.ts"
|
|
51
|
+
import { PIN_SITES } from "./init-agents.ts"
|
|
48
52
|
import { getRecipe, listRecipeVersions, type UpgradeRecipe } from "./recipes/index.ts"
|
|
49
53
|
import { markerTarget } from "./rules/comment-markers.ts"
|
|
50
54
|
|
|
@@ -97,6 +101,14 @@ export interface DependencyMoveChange {
|
|
|
97
101
|
readonly action: "renamed" | "removed"
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/** An agent file whose MCP launch pins another `@nifrajs/cli`, re-pinned to the target. */
|
|
105
|
+
export interface AgentPinChange {
|
|
106
|
+
readonly file: string
|
|
107
|
+
/** The versions it pinned, in order of appearance. */
|
|
108
|
+
readonly from: readonly string[]
|
|
109
|
+
readonly to: string
|
|
110
|
+
}
|
|
111
|
+
|
|
100
112
|
/** A retired comment in the project, and the line it applied to before the release retired it. */
|
|
101
113
|
export interface RetiredMarkerSite {
|
|
102
114
|
readonly file: string
|
|
@@ -110,6 +122,7 @@ export interface RetiredMarkerSite {
|
|
|
110
122
|
export interface UpgradePlan {
|
|
111
123
|
readonly version: string
|
|
112
124
|
readonly pins: readonly PinChange[]
|
|
125
|
+
readonly agentPins: readonly AgentPinChange[]
|
|
113
126
|
readonly dependencyMoves: readonly DependencyMoveChange[]
|
|
114
127
|
readonly importMoves: readonly ImportChange[]
|
|
115
128
|
/** Pins refused because the target is BELOW the installed version (a rollback). Empty when allowed. */
|
|
@@ -540,7 +553,7 @@ function scan(cwd: string, pattern: string, dot = false): string[] {
|
|
|
540
553
|
}
|
|
541
554
|
|
|
542
555
|
// The fixed version group: every package released together at one version.
|
|
543
|
-
const isFixedGroup = (name: string): boolean =>
|
|
556
|
+
export const isFixedGroup = (name: string): boolean =>
|
|
544
557
|
name.startsWith("@nifrajs/") || name === "nifra" || name === "create-nifra"
|
|
545
558
|
|
|
546
559
|
const groupPins = (version: string): UpgradeRecipe["pins"] => [
|
|
@@ -549,6 +562,20 @@ const groupPins = (version: string): UpgradeRecipe["pins"] => [
|
|
|
549
562
|
{ match: "nifra", to: version },
|
|
550
563
|
]
|
|
551
564
|
|
|
565
|
+
/** Every fixed-group package a package.json under `cwd` names, whatever its spec. */
|
|
566
|
+
export function groupDeclaredNames(cwd: string): ReadonlySet<string> {
|
|
567
|
+
const names = new Set<string>()
|
|
568
|
+
for (const rel of scan(cwd, "**/package.json")) {
|
|
569
|
+
try {
|
|
570
|
+
const parsed = JSON.parse(readFileSync(join(cwd, rel), "utf8")) as Record<string, unknown>
|
|
571
|
+
for (const { target } of pinEntries(parsed)) if (isFixedGroup(target)) names.add(target)
|
|
572
|
+
} catch {
|
|
573
|
+
// Not JSON: it declares nothing.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return names
|
|
577
|
+
}
|
|
578
|
+
|
|
552
579
|
/**
|
|
553
580
|
* Every fixed-group version the package.json files under `cwd` declare with a plain semver spec, each
|
|
554
581
|
* with the files that declare it. An override counts: it decides what installs. `workspace:`, `link:`
|
|
@@ -589,6 +616,7 @@ export interface StrayPin {
|
|
|
589
616
|
|
|
590
617
|
const STRAY_PIN_FILES = [
|
|
591
618
|
"**/{.mcp.json,mcp.json}",
|
|
619
|
+
"**/{AGENTS.md,CLAUDE.md}",
|
|
592
620
|
"**/{Dockerfile,Dockerfile.*,*.Dockerfile,*.dockerfile,Containerfile}",
|
|
593
621
|
"**/{docker-compose,compose}*.{yml,yaml}",
|
|
594
622
|
".github/workflows/*.{yml,yaml}",
|
|
@@ -601,10 +629,16 @@ const GROUP_PIN =
|
|
|
601
629
|
/(?<![\w.@/-])(@nifrajs\/[a-z0-9._-]+|create-nifra|nifra)@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g
|
|
602
630
|
|
|
603
631
|
/**
|
|
604
|
-
* Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs
|
|
605
|
-
* container files, CI workflows, and lockfiles besides
|
|
632
|
+
* Fixed-group versions other than `version` pinned where `nifra upgrade` does not edit: MCP configs and
|
|
633
|
+
* agent guidance beyond the launch pin it moves, container files, CI workflows, and lockfiles besides
|
|
634
|
+
* the workspace's own (which install rewrites). `planned` holds a file's text after the upgrade's own
|
|
635
|
+
* edits, so a dry run lists what `--write` would leave.
|
|
606
636
|
*/
|
|
607
|
-
export function strayPins(
|
|
637
|
+
export function strayPins(
|
|
638
|
+
cwd: string,
|
|
639
|
+
version: string,
|
|
640
|
+
planned: ReadonlyMap<string, string> = new Map(),
|
|
641
|
+
): StrayPin[] {
|
|
608
642
|
const found: StrayPin[] = []
|
|
609
643
|
const files = new Set(STRAY_PIN_FILES.flatMap((pattern) => scan(cwd, pattern, true)))
|
|
610
644
|
for (const rel of [...files].sort()) {
|
|
@@ -613,7 +647,7 @@ export function strayPins(cwd: string, version: string): StrayPin[] {
|
|
|
613
647
|
let text: string
|
|
614
648
|
try {
|
|
615
649
|
if (statSync(join(cwd, rel)).size > 8_000_000) continue
|
|
616
|
-
text = readFileSync(join(cwd, rel), "utf8")
|
|
650
|
+
text = planned.get(rel) ?? readFileSync(join(cwd, rel), "utf8")
|
|
617
651
|
} catch {
|
|
618
652
|
continue
|
|
619
653
|
}
|
|
@@ -635,6 +669,43 @@ export function strayPins(cwd: string, version: string): StrayPin[] {
|
|
|
635
669
|
return found
|
|
636
670
|
}
|
|
637
671
|
|
|
672
|
+
/** Whether `rel` is a regular file under `cwd` reached through no symlink: the upgrade's write boundary. */
|
|
673
|
+
function plainFileAt(cwd: string, rel: string): boolean {
|
|
674
|
+
let path = cwd
|
|
675
|
+
const parts = rel.split("/")
|
|
676
|
+
for (const [index, part] of parts.entries()) {
|
|
677
|
+
path = join(path, part)
|
|
678
|
+
let stat: ReturnType<typeof lstatSync>
|
|
679
|
+
try {
|
|
680
|
+
stat = lstatSync(path)
|
|
681
|
+
} catch {
|
|
682
|
+
return false
|
|
683
|
+
}
|
|
684
|
+
if (stat.isSymbolicLink()) return false
|
|
685
|
+
if (index === parts.length - 1 ? !stat.isFile() : !stat.isDirectory()) return false
|
|
686
|
+
}
|
|
687
|
+
return true
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* The MCP launch pin in each agent file at `cwd`, moved to `version`: the files and the edit of
|
|
692
|
+
* `nifra init-agents --sync-mcp`, so an agent launches the nifra the project installs once it does.
|
|
693
|
+
*/
|
|
694
|
+
function agentPinEdits(
|
|
695
|
+
cwd: string,
|
|
696
|
+
version: string,
|
|
697
|
+
): Array<{ readonly change: AgentPinChange; readonly text: string }> {
|
|
698
|
+
const edits: Array<{ change: AgentPinChange; text: string }> = []
|
|
699
|
+
for (const site of PIN_SITES) {
|
|
700
|
+
if (!plainFileAt(cwd, site.path)) continue
|
|
701
|
+
// latin1 is one char per byte, so every byte outside a pin is written back as it was.
|
|
702
|
+
const scan = site.repin(readFileSync(join(cwd, site.path), "latin1"), version)
|
|
703
|
+
if (scan.stale.length === 0) continue
|
|
704
|
+
edits.push({ change: { file: site.path, from: scan.stale, to: version }, text: scan.text })
|
|
705
|
+
}
|
|
706
|
+
return edits
|
|
707
|
+
}
|
|
708
|
+
|
|
638
709
|
/** An installed fixed-group package, followed to its real location. */
|
|
639
710
|
export interface LinkedGroupInstall {
|
|
640
711
|
readonly package: string
|
|
@@ -752,6 +823,8 @@ export function computeUpgrade(
|
|
|
752
823
|
exact = false,
|
|
753
824
|
): UpgradePlan {
|
|
754
825
|
const pins: PinChange[] = []
|
|
826
|
+
const agentPins: AgentPinChange[] = []
|
|
827
|
+
const planned = new Map<string, string>()
|
|
755
828
|
const dependencyMoves: DependencyMoveChange[] = []
|
|
756
829
|
const importMoves: ImportChange[] = []
|
|
757
830
|
const downgrades: PinChange[] = []
|
|
@@ -773,6 +846,12 @@ export function computeUpgrade(
|
|
|
773
846
|
}
|
|
774
847
|
}
|
|
775
848
|
|
|
849
|
+
for (const { change, text } of agentPinEdits(cwd, recipe.version)) {
|
|
850
|
+
agentPins.push(change)
|
|
851
|
+
planned.set(change.file, text)
|
|
852
|
+
if (write) writeFileSync(join(cwd, change.file), Buffer.from(text, "latin1"))
|
|
853
|
+
}
|
|
854
|
+
|
|
776
855
|
if (recipe.importMoves.length > 0) {
|
|
777
856
|
for (const rel of scan(cwd, SOURCE_GLOB)) {
|
|
778
857
|
const abs = join(cwd, rel)
|
|
@@ -808,12 +887,13 @@ export function computeUpgrade(
|
|
|
808
887
|
return {
|
|
809
888
|
version: recipe.version,
|
|
810
889
|
pins,
|
|
890
|
+
agentPins,
|
|
811
891
|
dependencyMoves,
|
|
812
892
|
importMoves,
|
|
813
893
|
downgrades,
|
|
814
894
|
retiredMarkers,
|
|
815
895
|
linkedOutside,
|
|
816
|
-
strayPins: strayPins(cwd, recipe.version),
|
|
896
|
+
strayPins: strayPins(cwd, recipe.version, planned),
|
|
817
897
|
notes: recipe.notes ?? [],
|
|
818
898
|
}
|
|
819
899
|
}
|
|
@@ -837,6 +917,7 @@ function renderPlan(
|
|
|
837
917
|
lines.push("")
|
|
838
918
|
if (
|
|
839
919
|
plan.pins.length === 0 &&
|
|
920
|
+
plan.agentPins.length === 0 &&
|
|
840
921
|
plan.dependencyMoves.length === 0 &&
|
|
841
922
|
plan.importMoves.length === 0 &&
|
|
842
923
|
plan.retiredMarkers.length === 0 &&
|
|
@@ -862,6 +943,13 @@ function renderPlan(
|
|
|
862
943
|
for (const p of plan.pins) lines.push(` ${p.file} ${p.name}: ${p.from} → ${p.to}`)
|
|
863
944
|
lines.push("")
|
|
864
945
|
}
|
|
946
|
+
if (plan.agentPins.length > 0) {
|
|
947
|
+
lines.push(`${verb} ${plan.agentPins.length} MCP launch pin(s):`)
|
|
948
|
+
for (const p of plan.agentPins)
|
|
949
|
+
lines.push(` ${p.file} @nifrajs/cli: ${p.from.join(", ")} → ${p.to}`)
|
|
950
|
+
if (write) lines.push(" Restart your agent so it launches the new nifra MCP server.")
|
|
951
|
+
lines.push("")
|
|
952
|
+
}
|
|
865
953
|
if (plan.importMoves.length > 0) {
|
|
866
954
|
const total = plan.importMoves.reduce((n, m) => n + m.count, 0)
|
|
867
955
|
lines.push(`${verb} ${total} import move(s) across ${plan.importMoves.length} file(s):`)
|
package/src/verification-plan.ts
CHANGED
|
@@ -37,6 +37,11 @@ const GATES = Object.freeze([
|
|
|
37
37
|
"Run `bun run typecheck` and fix the reported TypeScript errors.",
|
|
38
38
|
),
|
|
39
39
|
gate("tests", [["run", "test"]], "Run `bun run test` and fix the first failing test."),
|
|
40
|
+
gate(
|
|
41
|
+
"rest-tests",
|
|
42
|
+
[["run", "test:rest"]],
|
|
43
|
+
"Run `bun run test:rest` and fix the first failing test.",
|
|
44
|
+
),
|
|
40
45
|
gate(
|
|
41
46
|
"docs",
|
|
42
47
|
[["run", "check:docs"]],
|
|
@@ -216,13 +221,15 @@ const DEFAULT_PLAN = Object.freeze([
|
|
|
216
221
|
"changesets",
|
|
217
222
|
] as const)
|
|
218
223
|
|
|
224
|
+
// `coverage` fails on any failing test, so it is the release plan's run of the library suites;
|
|
225
|
+
// `rest-tests` runs the suites it leaves out, and `tests` would only repeat the two.
|
|
219
226
|
const RELEASE_PLAN = Object.freeze([
|
|
220
227
|
"build",
|
|
221
228
|
"lint",
|
|
222
229
|
"typecheck",
|
|
223
|
-
"tests",
|
|
224
|
-
"cli-isolation",
|
|
225
230
|
"coverage",
|
|
231
|
+
"rest-tests",
|
|
232
|
+
"cli-isolation",
|
|
226
233
|
"corpus",
|
|
227
234
|
"sitemap",
|
|
228
235
|
"docs",
|
package/src/version-drift.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { join, sep } from "node:path"
|
|
1
3
|
import {
|
|
2
4
|
compareSemverSpec,
|
|
5
|
+
groupDeclaredNames,
|
|
3
6
|
groupPinVersions,
|
|
4
7
|
installedGroupPackages,
|
|
8
|
+
isFixedGroup,
|
|
5
9
|
type LinkedGroupInstall,
|
|
6
10
|
} from "./upgrade.ts"
|
|
7
11
|
|
|
12
|
+
/** The fixed-group packages the package installed at `dir` depends on. */
|
|
13
|
+
function groupDependenciesAt(dir: string): string[] {
|
|
14
|
+
try {
|
|
15
|
+
const manifest: unknown = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))
|
|
16
|
+
if (typeof manifest !== "object" || manifest === null) return []
|
|
17
|
+
return ["dependencies", "peerDependencies", "optionalDependencies"].flatMap((field) => {
|
|
18
|
+
const deps: unknown = Reflect.get(manifest, field)
|
|
19
|
+
return typeof deps === "object" && deps !== null ? Object.keys(deps).filter(isFixedGroup) : []
|
|
20
|
+
})
|
|
21
|
+
} catch {
|
|
22
|
+
return []
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
/** One version of the fixed release group, and where the workspace carries it. */
|
|
9
27
|
export interface GroupVersion {
|
|
10
28
|
readonly version: string
|
|
@@ -35,7 +53,13 @@ export function collectVersionDrift(cwd: string, cliVersion?: string): VersionDr
|
|
|
35
53
|
for (const file of files) add(version, `pinned in ${file}`)
|
|
36
54
|
}
|
|
37
55
|
const linkedOutside: LinkedGroupInstall[] = []
|
|
38
|
-
|
|
56
|
+
const installs = installedGroupPackages(cwd)
|
|
57
|
+
// An install nothing declares, such as one an older install left behind, loads nowhere, and no
|
|
58
|
+
// upgrade re-pins it: counting it would report drift that never clears.
|
|
59
|
+
const declared = new Set(groupDeclaredNames(cwd))
|
|
60
|
+
for (const { path } of installs) for (const name of groupDependenciesAt(path)) declared.add(name)
|
|
61
|
+
for (const { package: name, path, version, inside } of installs) {
|
|
62
|
+
if (!declared.has(name)) continue
|
|
39
63
|
add(version, `installed ${name}${inside ? "" : ` (linked from ${path})`}`)
|
|
40
64
|
if (!inside) linkedOutside.push({ package: name, path, version })
|
|
41
65
|
}
|
|
@@ -51,6 +75,33 @@ export function collectVersionDrift(cwd: string, cliVersion?: string): VersionDr
|
|
|
51
75
|
}
|
|
52
76
|
}
|
|
53
77
|
|
|
78
|
+
/** The install a package belongs to: the folder above its last `node_modules`, or the package itself. */
|
|
79
|
+
const installRoot = (path: string): string => {
|
|
80
|
+
const parts = path.split(sep)
|
|
81
|
+
const last = parts.lastIndexOf("node_modules")
|
|
82
|
+
return last <= 0 ? path : parts.slice(0, last).join(sep)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Linked installs, one phrase per install and version: a linked repository brings its whole group. */
|
|
86
|
+
function linkedGroups(installs: readonly LinkedGroupInstall[]): string[] {
|
|
87
|
+
const groups = new Map<string, LinkedGroupInstall[]>()
|
|
88
|
+
for (const install of installs) {
|
|
89
|
+
const key = `${installRoot(install.path)}\0${install.version}`
|
|
90
|
+
groups.set(key, [...(groups.get(key) ?? []), install])
|
|
91
|
+
}
|
|
92
|
+
return [...groups.values()].map((group) => {
|
|
93
|
+
const [first] = group
|
|
94
|
+
if (first === undefined) return ""
|
|
95
|
+
if (group.length === 1) return `${first.package} ${first.version} at ${first.path}`
|
|
96
|
+
const names = group.map((install) => install.package).sort()
|
|
97
|
+
const listed =
|
|
98
|
+
names.length > 3
|
|
99
|
+
? `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`
|
|
100
|
+
: names.join(", ")
|
|
101
|
+
return `${group.length} packages at ${first.version} under ${installRoot(first.path)} (${listed})`
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
54
105
|
/** The advisory both `nifra doctor` and `nifra check` print. */
|
|
55
106
|
export function versionDriftMessage(drift: VersionDrift): string {
|
|
56
107
|
const listed = drift.versions
|
|
@@ -62,6 +113,6 @@ export function versionDriftMessage(drift: VersionDrift): string {
|
|
|
62
113
|
const linked =
|
|
63
114
|
drift.linkedOutside.length === 0
|
|
64
115
|
? ""
|
|
65
|
-
: ` It does not edit ${drift.linkedOutside
|
|
116
|
+
: ` It does not edit ${linkedGroups(drift.linkedOutside).join(", ")}, linked from outside the workspace root; upgrade that repository on its own.`
|
|
66
117
|
return `mixed nifra versions in this workspace: ${listed}. Run \`nifra upgrade ${drift.highest}\` (it runs each release's migration steps, then re-pins).${linked}`
|
|
67
118
|
}
|