pantheon-opencode 1.6.0-beta.5 → 1.6.0-beta.7
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/AGENTS.md +27 -820
- package/CHANGELOG.md +130 -0
- package/README.md +205 -30
- package/README.pt-BR.md +47 -23
- package/package.json +10 -5
- package/plugin.json +1 -1
- package/pyproject.toml +3 -1
- package/scripts/build-agents-md.mjs +162 -28
- package/scripts/doctor.mjs +104 -22
- package/scripts/install/agents-md.mjs +2 -2
- package/scripts/install/config-migration.mjs +154 -7
- package/scripts/install/opencode.mjs +202 -40
- package/scripts/install/shared.mjs +115 -7
- package/scripts/install/strings.mjs +8 -0
- package/scripts/probe-pantheon-cost.mjs +16 -3
- package/scripts/run-node-tests.mjs +189 -0
- package/scripts/sync-artifacts.mjs +2 -2
- package/scripts/test-opencode-v2-sandbox.sh +820 -86
- package/skills-lock.json +6 -5
- package/src/agents/aphrodite.md +17 -28
- package/src/agents/athena.md +1 -0
- package/src/agents/demeter.md +8 -18
- package/src/agents/hephaestus.md +10 -11
- package/src/agents/hermes.md +15 -30
- package/src/agents/iris.md +1 -0
- package/src/agents/nyx.md +1 -0
- package/src/agents/prometheus.md +16 -10
- package/src/agents/talos.md +4 -1
- package/src/agents/themis.md +5 -3
- package/src/agents/zeus.md +17 -280
- package/src/instructions/agent-return-format.instructions.md +4 -50
- package/src/instructions/backend-standards.instructions.md +2 -0
- package/src/instructions/frontend-standards.instructions.md +3 -1
- package/src/instructions/memory-protocol.instructions.md +12 -121
- package/src/instructions/zeus-anti-stall.instructions.md +8 -111
- package/src/instructions/zeus-communication-rules.instructions.md +0 -1
- package/src/instructions/zeus-memory-operations.instructions.md +26 -0
- package/src/instructions/zeus-timeout-retry.instructions.md +9 -130
- package/src/pantheon/secret-scanner.ts +489 -0
- package/src/pantheon/v2-delegation-enforce.ts +86 -0
- package/src/pantheon/v2-execute-after.ts +186 -0
- package/src/pantheon/v2-unsupported.mjs +0 -23
- package/src/plugin-v2.ts +254 -102
- package/src/plugins/pantheon-hooks.ts +82 -35
- package/src/plugins/tui/README.md +14 -0
- package/src/plugins/tui/dist/tui.d.ts +11 -8
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +102 -62
- package/src/plugins/tui/package-lock.json +30 -33
- package/src/plugins/tui/package.json +5 -1
- package/src/plugins/tui/src/index.tsx +163 -107
- package/src/plugins/tui/tsdown.config.ts +17 -0
- package/src/routing.yml +12 -9
- package/src/skills/README.md +9 -4
- package/src/skills/agent-coordination/SKILL.md +13 -83
- package/src/skills/artifact-management/SKILL.md +12 -5
- package/src/skills/auto-continue/SKILL.md +38 -28
- package/src/skills/code-review-checklist/SKILL.md +5 -5
- package/src/skills/council-synthesis/SKILL.md +29 -0
- package/src/skills/tdd-with-agents/SKILL.md +18 -10
- package/scripts/hooks/scan-secrets.sh +0 -97
- package/src/instructions/zeus-council-synthesis.instructions.md +0 -299
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
copyFileSync,
|
|
8
8
|
cpSync,
|
|
9
9
|
existsSync,
|
|
10
|
+
lstatSync,
|
|
10
11
|
mkdirSync,
|
|
11
12
|
readdirSync,
|
|
12
13
|
readFileSync,
|
|
@@ -257,17 +258,105 @@ export function copyFiles(srcDir, dstDir, dryRun, renameMap = null, clean = fals
|
|
|
257
258
|
return { created, skipped }
|
|
258
259
|
}
|
|
259
260
|
|
|
260
|
-
|
|
261
|
-
|
|
261
|
+
/**
|
|
262
|
+
* `lstat` that returns `null` instead of throwing for an absent path.
|
|
263
|
+
*
|
|
264
|
+
* Deliberately `lstat`, not `stat`/`existsSync`: both of those follow the link,
|
|
265
|
+
* so a DANGLING symlink reads as absent and the atomic rename below would then
|
|
266
|
+
* silently replace the link itself. `lstat` sees the link and lets the write
|
|
267
|
+
* guard refuse it.
|
|
268
|
+
*/
|
|
269
|
+
function lstatOrNull(filePath) {
|
|
270
|
+
try {
|
|
271
|
+
return lstatSync(filePath)
|
|
272
|
+
} catch {
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Read a path's raw text, or `null` when it cannot be read (absent, dangling
|
|
279
|
+
* symlink target, permission error). The caller decides what "unreadable"
|
|
280
|
+
* means; this never throws.
|
|
281
|
+
*/
|
|
282
|
+
function readFileOrNull(filePath) {
|
|
283
|
+
try {
|
|
284
|
+
return readFileSync(filePath, 'utf8')
|
|
285
|
+
} catch {
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Atomically create/overwrite a text file, skipping an unchanged write.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} filePath - destination path
|
|
294
|
+
* @param {string} content - full file content
|
|
295
|
+
* @param {boolean} dryRun - when true, compute the outcome without writing
|
|
296
|
+
* @param {object} [options]
|
|
297
|
+
* @param {string|null} [options.expectedContent] - the raw content the caller
|
|
298
|
+
* read this path from, if any. When supplied, the write proceeds only if the
|
|
299
|
+
* on-disk content STILL matches it — compared once up front AND again
|
|
300
|
+
* immediately before the rename; otherwise it returns `'conflict'` and leaves
|
|
301
|
+
* the file alone. This is a best-effort COMPARE-BEFORE-WRITE guard, NOT a
|
|
302
|
+
* filesystem CAS: the final re-read and the rename are still two syscalls, so
|
|
303
|
+
* a writer that lands between them is not prevented. What it does close is
|
|
304
|
+
* the window the installer actually races with — a config the caller parsed
|
|
305
|
+
* going stale during the same run — and it keeps the target and the `.bak`
|
|
306
|
+
* consistent with the bytes that were checked.
|
|
307
|
+
* @param {(existing: string|null) => void} [options.beforeRecheck] - runs after
|
|
308
|
+
* the initial content check and before the last-moment re-read. Injection
|
|
309
|
+
* point so tests can simulate a concurrent writer landing in the window;
|
|
310
|
+
* production callers omit it (undefined is a no-op).
|
|
311
|
+
* @param {(existing: string|null) => void} [options.beforeSwap] - runs after the
|
|
312
|
+
* expected-content checks pass and immediately before the rename, so side
|
|
313
|
+
* effects that must not fire on a conflict (e.g. the `.bak` copy) live here.
|
|
314
|
+
* @returns {'created'|'skipped'|'conflict'}
|
|
315
|
+
*/
|
|
316
|
+
export function writeIfChanged(filePath, content, dryRun, options = {}) {
|
|
317
|
+
const { expectedContent, beforeSwap, beforeRecheck } = options
|
|
318
|
+
|
|
319
|
+
// Refuse to write through/over a symlink. A dangling symlink is invisible to
|
|
320
|
+
// existsSync, and rename-over-link would replace the link rather than its
|
|
321
|
+
// target — both are silent redirections the installer must not perform.
|
|
322
|
+
const stat = lstatOrNull(filePath)
|
|
323
|
+
if (stat?.isSymbolicLink()) {
|
|
324
|
+
throw new Error(`Refusing to write through symlink: ${filePath}`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const existing = stat === null ? null : readFileOrNull(filePath)
|
|
262
328
|
if (existing === content) {
|
|
263
329
|
return 'skipped'
|
|
264
330
|
}
|
|
331
|
+
if (expectedContent !== undefined && existing !== expectedContent) {
|
|
332
|
+
// The file changed since the caller read it (or appeared/disappeared).
|
|
333
|
+
// Do not clobber the newer content; the caller decides how to react.
|
|
334
|
+
return 'conflict'
|
|
335
|
+
}
|
|
265
336
|
if (!dryRun) {
|
|
337
|
+
// Seam for a concurrent writer to land between the read above and the
|
|
338
|
+
// re-read below. No production caller passes this.
|
|
339
|
+
if (typeof beforeRecheck === 'function') beforeRecheck(existing)
|
|
340
|
+
|
|
266
341
|
// Atomic write: write a temp sibling and rename over the target so a
|
|
267
342
|
// crash mid-write can never leave a truncated file behind (rename is
|
|
268
343
|
// atomic within the same directory).
|
|
269
344
|
const tmpPath = `${filePath}.tmp-${process.pid}`
|
|
270
345
|
writeFileSync(tmpPath, content, 'utf8')
|
|
346
|
+
|
|
347
|
+
// Last-moment re-read. The initial compare and this rename are separate
|
|
348
|
+
// syscalls, so a concurrent writer can land in between; re-check the
|
|
349
|
+
// expected bytes immediately before the swap. On a mismatch, drop the temp
|
|
350
|
+
// and leave the target AND any existing `.bak` byte-identical.
|
|
351
|
+
if (expectedContent !== undefined) {
|
|
352
|
+
const current = readFileOrNull(filePath)
|
|
353
|
+
if (current !== expectedContent) {
|
|
354
|
+
rmSync(tmpPath, { force: true })
|
|
355
|
+
return 'conflict'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (typeof beforeSwap === 'function') beforeSwap(existing)
|
|
271
360
|
renameSync(tmpPath, filePath)
|
|
272
361
|
}
|
|
273
362
|
return 'created'
|
|
@@ -277,12 +366,31 @@ export function writeIfChanged(filePath, content, dryRun) {
|
|
|
277
366
|
* beta.5: write the user's opencode.json with a same-directory backup of the
|
|
278
367
|
* previous content. The installer rewrites the whole config; a `.bak` gives
|
|
279
368
|
* the user a one-step recovery if a merge ever goes wrong.
|
|
369
|
+
*
|
|
370
|
+
* The backup is created INSIDE the `beforeSwap` hook, so it fires only after
|
|
371
|
+
* both expected-content checks pass (the initial compare and the last-moment
|
|
372
|
+
* re-read before the rename). A conflict observed at either point leaves the
|
|
373
|
+
* target AND any pre-existing `.bak` untouched, rather than overwriting the
|
|
374
|
+
* user's last known-good backup with a stale copy on a no-op write.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} filePath
|
|
377
|
+
* @param {string} content
|
|
378
|
+
* @param {boolean} dryRun
|
|
379
|
+
* @param {object} [options] - forwarded to {@link writeIfChanged}
|
|
280
380
|
*/
|
|
281
|
-
export function writeConfigWithBackup(filePath, content, dryRun) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
381
|
+
export function writeConfigWithBackup(filePath, content, dryRun, options = {}) {
|
|
382
|
+
return writeIfChanged(filePath, content, dryRun, {
|
|
383
|
+
expectedContent: options.expectedContent,
|
|
384
|
+
beforeRecheck: options.beforeRecheck,
|
|
385
|
+
beforeSwap: (existing) => {
|
|
386
|
+
if (existing === null) return
|
|
387
|
+
try {
|
|
388
|
+
copyFileSync(filePath, `${filePath}.bak`)
|
|
389
|
+
} catch {
|
|
390
|
+
// Best-effort: a backup failure must not block the install.
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
})
|
|
286
394
|
}
|
|
287
395
|
|
|
288
396
|
/**
|
|
@@ -47,6 +47,10 @@ const TABLE = {
|
|
|
47
47
|
prereqMissing:
|
|
48
48
|
'runtime prerequisites missing — install the tools above, or re-run with --no-mcp to install without Python MCP servers',
|
|
49
49
|
v2Migrating: 'Migrating config to V2 native format...',
|
|
50
|
+
v2UnpublishedPin: (spec) =>
|
|
51
|
+
`V2 plugin is pinned to ${spec}, but this installer is running from a source checkout — ` +
|
|
52
|
+
'that version may not be published to npm yet, so OpenCode cannot resolve the pin until it is. ' +
|
|
53
|
+
'Run the installer from the published package (npx pantheon-opencode init) to avoid this.',
|
|
50
54
|
// summary (printSummary)
|
|
51
55
|
summaryInstalled: (target) => `OpenCode installed in ${target}`,
|
|
52
56
|
summaryWithErrors: (target) => `Installation finished WITH ERRORS in ${target}`,
|
|
@@ -95,6 +99,10 @@ const TABLE = {
|
|
|
95
99
|
prereqMissing:
|
|
96
100
|
'pré-requisitos do runtime ausentes — instale as ferramentas acima, ou rode de novo com --no-mcp para instalar sem os MCPs Python',
|
|
97
101
|
v2Migrating: 'Migrando config para o formato nativo V2...',
|
|
102
|
+
v2UnpublishedPin: (spec) =>
|
|
103
|
+
`Plugin V2 fixado em ${spec}, mas este installer está rodando de um checkout do código-fonte — ` +
|
|
104
|
+
'essa versão pode ainda não estar publicada no npm, então o OpenCode não resolve o pin até a publicação. ' +
|
|
105
|
+
'Rode o installer pelo pacote publicado (npx pantheon-opencode init) para evitar isso.',
|
|
98
106
|
// summary (printSummary)
|
|
99
107
|
summaryInstalled: (target) => `OpenCode instalado em ${target}`,
|
|
100
108
|
summaryWithErrors: (target) => `Instalação concluída COM ERROS em ${target}`,
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* temporary synthetic opencode database. A missing fixture runtime is also
|
|
9
9
|
* AMBIENTAL rather than a false PASS.
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs'
|
|
12
12
|
import { tmpdir } from 'node:os'
|
|
13
13
|
import { join } from 'node:path'
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
15
|
|
|
15
16
|
const requestedVersion = valueFor('--version')
|
|
16
17
|
const jsonOutput = process.argv.includes('--json')
|
|
@@ -151,19 +152,27 @@ async function main() {
|
|
|
151
152
|
return
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
|
|
154
156
|
let DatabaseSync
|
|
155
157
|
let createCostCommand
|
|
156
158
|
try {
|
|
157
159
|
;({ DatabaseSync } = await import('node:sqlite'))
|
|
158
|
-
|
|
160
|
+
// Node's built-in TS stripping deliberately refuses files under
|
|
161
|
+
// node_modules. Exercise the shipped command from an isolated temp copy
|
|
162
|
+
// while resolving its runtime dependencies through the installed package.
|
|
163
|
+
const runtimeSource = join(root, 'cost-command.ts')
|
|
164
|
+
const packageNodeModules = fileURLToPath(new URL('../node_modules/', import.meta.url))
|
|
165
|
+
copyFileSync(fileURLToPath(source), runtimeSource)
|
|
166
|
+
symlinkSync(packageNodeModules, join(root, 'node_modules'), 'dir')
|
|
167
|
+
;({ createCostCommand } = await import(pathToFileURL(runtimeSource).href))
|
|
159
168
|
} catch (error) {
|
|
169
|
+
rmSync(root, { recursive: true, force: true })
|
|
160
170
|
ambiental(
|
|
161
171
|
`pantheon_cost fixture runtime unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
162
172
|
)
|
|
163
173
|
return
|
|
164
174
|
}
|
|
165
175
|
|
|
166
|
-
const root = mkdtempSync(join(tmpdir(), 'pantheon-cost-probe-'))
|
|
167
176
|
const dataHome = join(root, 'data')
|
|
168
177
|
const opencodeDir = join(dataHome, 'opencode')
|
|
169
178
|
// Every real installation writes `opencode.db`; the v1/v2 distinction is the
|
|
@@ -254,6 +263,7 @@ async function main() {
|
|
|
254
263
|
|
|
255
264
|
const restore = setEnvironment({
|
|
256
265
|
XDG_DATA_HOME: dataHome,
|
|
266
|
+
OPENCODE_DB: undefined,
|
|
257
267
|
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
258
268
|
PANTHEON_COST_DB: undefined,
|
|
259
269
|
})
|
|
@@ -284,6 +294,7 @@ async function main() {
|
|
|
284
294
|
|
|
285
295
|
const envRestore = setEnvironment({
|
|
286
296
|
XDG_DATA_HOME: dataHome,
|
|
297
|
+
OPENCODE_DB: undefined,
|
|
287
298
|
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
288
299
|
PANTHEON_COST_DB: envDb,
|
|
289
300
|
})
|
|
@@ -308,6 +319,7 @@ async function main() {
|
|
|
308
319
|
|
|
309
320
|
const explicitRestore = setEnvironment({
|
|
310
321
|
XDG_DATA_HOME: dataHome,
|
|
322
|
+
OPENCODE_DB: undefined,
|
|
311
323
|
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
312
324
|
PANTHEON_COST_DB: envDb,
|
|
313
325
|
})
|
|
@@ -329,6 +341,7 @@ async function main() {
|
|
|
329
341
|
|
|
330
342
|
const missingRestore = setEnvironment({
|
|
331
343
|
XDG_DATA_HOME: join(root, 'missing-data'),
|
|
344
|
+
OPENCODE_DB: undefined,
|
|
332
345
|
PANTHEON_OPENCODE_VERSION: requestedVersion,
|
|
333
346
|
PANTHEON_COST_DB: undefined,
|
|
334
347
|
})
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Run `node --test` over an explicit file list, and FAIL when zero tests are collected.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: `node --test <glob>` exits 0 when the glob matches nothing (Node >= 22).
|
|
6
|
+
* A test script whose file set is absent from the checkout -- or absent from the published
|
|
7
|
+
* package, because the `files` allow-list omits it -- therefore reports success while
|
|
8
|
+
* collecting nothing. That is the worst possible failure mode for a release gate: it
|
|
9
|
+
* manufactures confidence.
|
|
10
|
+
*
|
|
11
|
+
* This runner closes that hole from two independent sides:
|
|
12
|
+
* 1. It expands the globs ITSELF and refuses to start the runner with zero files, so the
|
|
13
|
+
* runner is never handed an unexpanded pattern that silently matches nothing.
|
|
14
|
+
* 2. After a run that exited 0, it re-reads the collected count from the machine-readable
|
|
15
|
+
* TAP summary and fails closed when that count is 0, unparseable, or missing.
|
|
16
|
+
*
|
|
17
|
+
* Side 2 is deliberately independent of side 1: a future Node change to `--test` glob
|
|
18
|
+
* handling, a glob that expands to a directory of empty files, or an emptied test file all
|
|
19
|
+
* still get caught.
|
|
20
|
+
*
|
|
21
|
+
* Human-readable output is unchanged: the default `spec` reporter still goes to stdout, and
|
|
22
|
+
* the TAP reporter is diverted to a scratch file that is read once and deleted.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...
|
|
26
|
+
*
|
|
27
|
+
* Examples:
|
|
28
|
+
* node scripts/run-node-tests.mjs 'tests/*.mjs' 'tests/pantheon/*.mjs'
|
|
29
|
+
* node scripts/run-node-tests.mjs -- --experimental-test-coverage 'tests/*.mjs'
|
|
30
|
+
*/
|
|
31
|
+
import { spawnSync } from 'node:child_process'
|
|
32
|
+
import { globSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
33
|
+
import { tmpdir } from 'node:os'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
|
|
37
|
+
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
|
|
38
|
+
|
|
39
|
+
function usage(message) {
|
|
40
|
+
console.error(`zero-test-guard: ${message}`)
|
|
41
|
+
console.error(
|
|
42
|
+
'usage: node scripts/run-node-tests.mjs [--label <name>] [-- <node flags...>] <glob>...',
|
|
43
|
+
)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse `[--label <name>] [-- <node flags...>] <glob>...`. */
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const separator = argv.indexOf('--')
|
|
50
|
+
const own = separator === -1 ? argv : argv.slice(0, separator)
|
|
51
|
+
const nodeFlags = separator === -1 ? [] : argv.slice(separator + 1)
|
|
52
|
+
|
|
53
|
+
let label = 'node tests'
|
|
54
|
+
const globs = []
|
|
55
|
+
for (let i = 0; i < own.length; i++) {
|
|
56
|
+
if (own[i] === '--label') {
|
|
57
|
+
const value = own[i + 1]
|
|
58
|
+
if (value === undefined) usage('--label requires a value')
|
|
59
|
+
label = value
|
|
60
|
+
i += 1
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (own[i].startsWith('-')) usage(`unknown option ${own[i]}`)
|
|
64
|
+
globs.push(own[i])
|
|
65
|
+
}
|
|
66
|
+
if (globs.length === 0) usage('at least one glob is required')
|
|
67
|
+
return { label, globs, nodeFlags }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Expand globs to a deduped, ordered file list.
|
|
72
|
+
* Order follows the positional order of the globs so that the suite runs in the same order
|
|
73
|
+
* as the shell used to; within a glob, matches are sorted for determinism.
|
|
74
|
+
*/
|
|
75
|
+
function expand(globs) {
|
|
76
|
+
const files = []
|
|
77
|
+
const seen = new Set()
|
|
78
|
+
for (const pattern of globs) {
|
|
79
|
+
const matches = globSync(pattern, { cwd: ROOT })
|
|
80
|
+
.filter((match) => !match.includes('node_modules') && !match.includes('__pycache__'))
|
|
81
|
+
.sort()
|
|
82
|
+
for (const match of matches) {
|
|
83
|
+
if (seen.has(match)) continue
|
|
84
|
+
seen.add(match)
|
|
85
|
+
files.push(match)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return files
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reportMissingFiles(label, globs) {
|
|
92
|
+
console.error(`zero-test-guard: ${label} matched 0 test files — refusing to report success.`)
|
|
93
|
+
console.error(` globs: ${globs.join(' ')}`)
|
|
94
|
+
console.error(' This is the "test suite silently passes when it collects nothing" failure.')
|
|
95
|
+
console.error(' Either the test files are missing from this checkout, or they are missing')
|
|
96
|
+
console.error(' from the published package (check the "files" allow-list in package.json).')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reportZeroCollected(label, files) {
|
|
100
|
+
console.error(`zero-test-guard: ${label} collected 0 tests — refusing to report success.`)
|
|
101
|
+
console.error(` ${files.length} file(s) matched, but not one test case was collected:`)
|
|
102
|
+
for (const file of files) console.error(` ${file}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Count the real test cases collected in a TAP summary.
|
|
107
|
+
*
|
|
108
|
+
* Node counts a test FILE that declares no test cases as one passing "test"
|
|
109
|
+
* (`ok 1 - tests/foo.test.mjs`), so the summary's `# tests N` is >= the file count and
|
|
110
|
+
* can never reach 0 once any file matched. Relying on it alone would let a suite whose
|
|
111
|
+
* every test file was emptied out report success.
|
|
112
|
+
*
|
|
113
|
+
* The reliable discriminator is the subtest NAME: a file-level placeholder is named with
|
|
114
|
+
* the file path that was passed to the runner, a real test case is named with its test
|
|
115
|
+
* name. So a top-level subtest whose name is one of our matched paths is a placeholder
|
|
116
|
+
* and is not counted as a collected case.
|
|
117
|
+
*
|
|
118
|
+
* Returns null when the summary cannot be read or parsed -- the caller fails closed.
|
|
119
|
+
*/
|
|
120
|
+
function countCollectedTests(tapPath, files) {
|
|
121
|
+
let tap = ''
|
|
122
|
+
try {
|
|
123
|
+
tap = readFileSync(tapPath, 'utf8')
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
// The summary count is still asserted to exist: without it the stream is not TAP.
|
|
128
|
+
if (!/^#\s*tests\s+\d+\s*$/m.test(tap)) return null
|
|
129
|
+
|
|
130
|
+
const placeholders = new Set(files)
|
|
131
|
+
let collected = 0
|
|
132
|
+
for (const match of tap.matchAll(/^# Subtest: (.+)$/gm)) {
|
|
133
|
+
if (placeholders.has(match[1].trim())) continue
|
|
134
|
+
collected += 1
|
|
135
|
+
}
|
|
136
|
+
return collected
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { label, globs, nodeFlags } = parseArgs(process.argv.slice(2))
|
|
140
|
+
const files = expand(globs)
|
|
141
|
+
|
|
142
|
+
if (files.length === 0) {
|
|
143
|
+
reportMissingFiles(label, globs)
|
|
144
|
+
process.exit(1)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scratch = mkdtempSync(join(tmpdir(), 'pantheon-node-tests-'))
|
|
148
|
+
const tapPath = join(scratch, 'summary.tap')
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const run = spawnSync(
|
|
152
|
+
process.execPath,
|
|
153
|
+
[
|
|
154
|
+
'--test',
|
|
155
|
+
'--test-reporter=spec',
|
|
156
|
+
'--test-reporter-destination=stdout',
|
|
157
|
+
'--test-reporter=tap',
|
|
158
|
+
`--test-reporter-destination=${tapPath}`,
|
|
159
|
+
...nodeFlags,
|
|
160
|
+
...files,
|
|
161
|
+
],
|
|
162
|
+
{ cwd: ROOT, stdio: 'inherit' },
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if (run.error) {
|
|
166
|
+
console.error(`zero-test-guard: could not start the test runner: ${run.error.message}`)
|
|
167
|
+
process.exit(1)
|
|
168
|
+
}
|
|
169
|
+
if (run.signal) {
|
|
170
|
+
console.error(`zero-test-guard: the test runner was killed by ${run.signal}`)
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
173
|
+
// Real failures keep the runner's own exit code; the guard only speaks up when the
|
|
174
|
+
// runner claimed success it cannot justify.
|
|
175
|
+
if (run.status !== 0) process.exit(run.status)
|
|
176
|
+
|
|
177
|
+
const collected = countCollectedTests(tapPath, files)
|
|
178
|
+
if (collected === null) {
|
|
179
|
+
console.error('zero-test-guard: could not read the collected test count from the TAP summary.')
|
|
180
|
+
console.error(' Failing closed: an unreadable summary cannot certify that any test ran.')
|
|
181
|
+
process.exit(1)
|
|
182
|
+
}
|
|
183
|
+
if (collected === 0) {
|
|
184
|
+
reportZeroCollected(label, files)
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
189
|
+
}
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
21
21
|
import { join } from 'node:path'
|
|
22
|
+
import { copyAgentPrompts } from './build-agents-md.mjs'
|
|
22
23
|
|
|
23
24
|
import {
|
|
24
25
|
collectSkillNames,
|
|
25
|
-
copyFiles,
|
|
26
26
|
installSkills,
|
|
27
27
|
ROOT,
|
|
28
28
|
syncDir,
|
|
@@ -59,7 +59,7 @@ export function syncCopyArtifacts(configDir) {
|
|
|
59
59
|
const dstAgents = join(configDir, 'agents')
|
|
60
60
|
if (existsSync(join(ROOT, 'src', 'agents'))) {
|
|
61
61
|
mkdirSync(dstAgents, { recursive: true })
|
|
62
|
-
bump(
|
|
62
|
+
bump(copyAgentPrompts(join(ROOT, 'src', 'agents'), dstAgents, false))
|
|
63
63
|
const routing = join(ROOT, 'src', 'routing.yml')
|
|
64
64
|
if (existsSync(routing)) {
|
|
65
65
|
if (
|