mandrel 2.18.0 → 2.20.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/.agents/docs/SDLC.md +1 -1
- package/.agents/docs/agentrc-reference.json +0 -21
- package/.agents/docs/configuration.md +11 -14
- package/.agents/docs/execution-reference.md +8 -5
- package/.agents/schemas/agentrc.schema.json +0 -31
- package/.agents/scripts/check-doc-links.js +141 -9
- package/.agents/scripts/check-test-temp-hygiene.js +153 -14
- package/.agents/scripts/lib/baselines/env-overrides.js +40 -48
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +3 -4
- package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -2
- package/.agents/scripts/lib/config/explain.js +0 -8
- package/.agents/scripts/lib/config/temp-paths.js +12 -1
- package/.agents/scripts/lib/config-settings-schema.js +8 -24
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +51 -77
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +20 -12
- package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +2 -1
- package/.agents/scripts/lib/orchestration/plan-context.js +13 -14
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -66
- package/.agents/scripts/lib/test-env.js +15 -3
- package/.agents/scripts/lib/test-temp.js +311 -0
- package/.agents/workflows/audit-performance.md +2 -2
- package/.agents/workflows/helpers/diagnose.md +1 -1
- package/.agents/workflows/helpers/plan-reference.md +19 -4
- package/.agents/workflows/helpers/signals.md +2 -2
- package/.agents/workflows/mandrel-update.md +4 -4
- package/.agents/workflows/plan.md +7 -6
- package/docs/CHANGELOG.md +16 -0
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +113 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/codebase-snapshot.js +0 -513
- package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +0 -147
- package/.agents/scripts/lib/orchestration/spec-freshness.js +0 -129
package/.agents/docs/SDLC.md
CHANGED
|
@@ -528,7 +528,7 @@ new CI gate**, route the check through a `package.json` script (add it to
|
|
|
528
528
|
transitivity. **When a workflow file genuinely must change** (a new job, a
|
|
529
529
|
trigger change, a runner bump), the edit must be made by an operator with
|
|
530
530
|
`Workflows: Read and write` PAT permissions — see
|
|
531
|
-
[`docs/release-operations.md` § One-time PAT setup](
|
|
531
|
+
[`docs/release-operations.md` § One-time PAT setup](https://github.com/dsj1984/mandrel/blob/main/docs/release-operations.md#one-time-pat-setup).
|
|
532
532
|
|
|
533
533
|
### Worktree config shadow
|
|
534
534
|
|
|
@@ -67,27 +67,6 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"planning": {
|
|
70
|
-
"codebaseSnapshot": {
|
|
71
|
-
"tier": "skinny",
|
|
72
|
-
"include": [
|
|
73
|
-
".agents/scripts/**",
|
|
74
|
-
"src/**",
|
|
75
|
-
"lib/**",
|
|
76
|
-
"app/**",
|
|
77
|
-
"packages/**"
|
|
78
|
-
],
|
|
79
|
-
"exclude": [
|
|
80
|
-
"**/node_modules/**",
|
|
81
|
-
"**/dist/**",
|
|
82
|
-
"**/build/**",
|
|
83
|
-
"**/.next/**",
|
|
84
|
-
"**/.turbo/**",
|
|
85
|
-
"**/coverage/**",
|
|
86
|
-
"**/*.test.*",
|
|
87
|
-
"**/*.spec.*"
|
|
88
|
-
],
|
|
89
|
-
"recentCommitWindow": 30
|
|
90
|
-
},
|
|
91
70
|
"riskHeuristics": [
|
|
92
71
|
"Destructive or irreversible data mutations (dropping tables, deleting rows without soft-delete or backup, truncating production state).",
|
|
93
72
|
"Modifications to shared security or auth infrastructure (IAM policies, auth middleware, session or token handling, secret rotation).",
|
|
@@ -26,7 +26,7 @@ with the runtime validators.
|
|
|
26
26
|
"$schema": "./.agents/schemas/agentrc.schema.json",
|
|
27
27
|
"project": { /* paths, commands, baseBranch, docsContextFiles */ },
|
|
28
28
|
"github": { /* owner, repo, branchProtection, mergeMethods, notifications */ },
|
|
29
|
-
"planning": { /* riskHeuristics, conflict gates,
|
|
29
|
+
"planning": { /* riskHeuristics, conflict gates, complexityGate, navigation */ },
|
|
30
30
|
"delivery": { /* execution, quality, worktreeIsolation, deliverRunner, ... */ }
|
|
31
31
|
}
|
|
32
32
|
```
|
|
@@ -38,7 +38,7 @@ top-level keys are validation errors.
|
|
|
38
38
|
| ------------- | -------- | ---------------------------------------------------------------------------------- |
|
|
39
39
|
| `project` | **Yes** | Project-local paths, base branch, validation commands, and context-hydration files. |
|
|
40
40
|
| `github` | No | Ticketing provider config: owner/repo, branch protection, merge methods, notifications. |
|
|
41
|
-
| `planning` | No | `/plan` tuning: conflict advisories,
|
|
41
|
+
| `planning` | No | `/plan` tuning: conflict advisories, complexity routing, navigability gate. (Story sizing ceilings and the planner-context cap are code-absolute — not agentrc.) |
|
|
42
42
|
| `delivery` | No | `/deliver` tuning: quality gates, worktree isolation, runners, CI watch, code-review providers. |
|
|
43
43
|
| `$schema` | No | JSON Schema pointer for editor tooling. |
|
|
44
44
|
|
|
@@ -97,11 +97,6 @@ top-level keys are validation errors.
|
|
|
97
97
|
| Key | Required | Type | Default | Description |
|
|
98
98
|
| --- | --- | --- | --- | --- |
|
|
99
99
|
| `riskHeuristics` | No | `string[]` or `{ append?, prepend? }` | — | — |
|
|
100
|
-
| `codebaseSnapshot` | No | `object` | — | Nested configuration block. |
|
|
101
|
-
| `codebaseSnapshot.tier` | No | `"skinny"` \| `"medium"` | — | — |
|
|
102
|
-
| `codebaseSnapshot.include` | No | `array<string>` | — | — |
|
|
103
|
-
| `codebaseSnapshot.exclude` | No | `array<string>` | — | — |
|
|
104
|
-
| `codebaseSnapshot.recentCommitWindow` | No | `integer` | — | — |
|
|
105
100
|
| `complexityGate` | No | `object` | — | Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables. |
|
|
106
101
|
| `complexityGate.enabled` | No | `boolean` | — | Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true. |
|
|
107
102
|
| `complexityGate.maxArtifacts` | No | `integer` | — | Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict — carries no routing authority. Default 1. |
|
|
@@ -352,13 +347,15 @@ Defaults are **advisory, not Story-width ceilings**. Session-mass ceilings live
|
|
|
352
347
|
as absolute authored-token constants on `DEFAULT_MODEL_CAPACITY` in
|
|
353
348
|
`ticket-validator-sizing.js` (soft 30k / hard 75k); there is no `maxTokenBudget`
|
|
354
349
|
envelope, and cohesion / split policy / conflict advisories are the primary
|
|
355
|
-
sizing signal.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
`
|
|
361
|
-
|
|
350
|
+
sizing signal.
|
|
351
|
+
|
|
352
|
+
There is no snapshot knob to tune: Story #4811 retired the structural-snapshot
|
|
353
|
+
block outright. Spec authoring is grounded by the author's own targeted repo
|
|
354
|
+
retrieval plus the Phase 8 `validateStoryFileAssumptions` gate, which probes
|
|
355
|
+
every authored `{path, assumption}` against the real tree as a hard error — a
|
|
356
|
+
pre-computed inventory added a second, staler answer to the same question.
|
|
357
|
+
A config still carrying the retired key is a hard validation failure; the
|
|
358
|
+
2.20.0 retirement migration strips it on upgrade.
|
|
362
359
|
|
|
363
360
|
- **`complexityGate`.** Shape-derived ceremony-lite routing (Story #4722,
|
|
364
361
|
superseding the word-count gate of Stories #4683/#4707). The full ceremony
|
|
@@ -96,12 +96,15 @@ over-ceiling envelope or an over-budget Story count.
|
|
|
96
96
|
- **`PLAN_CONTEXT_ENVELOPE_BYTE_CEILING`** (`lib/orchestration/plan-context.js`):
|
|
97
97
|
256 KB (≈64K tokens at the ≈4-chars/token estimate) on the serialized
|
|
98
98
|
envelope `buildPlanContext` assembles, checked at the single choke point
|
|
99
|
-
every mode returns through.
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
every mode returns through. A measured seed-mode envelope on this repo is
|
|
100
|
+
~120 KB — `docsContext` (~63 KB) and `systemPrompts` (~54 KB) are the whole
|
|
101
|
+
of it, every other field under 1 KB — so the ceiling carries roughly 2×
|
|
102
|
+
headroom. Story #4811 retired the codebase snapshot that used to sit
|
|
103
|
+
alongside them, leaving the operator-supplied seed the only unbounded
|
|
104
|
+
contributor.
|
|
102
105
|
- **On refusal**, the error names the envelope's largest fields. Trim the seed,
|
|
103
|
-
plan fewer `--tickets` source issues in one run
|
|
104
|
-
|
|
106
|
+
or plan fewer `--tickets` source issues in one run. The seed is carried
|
|
107
|
+
**verbatim** by design — it
|
|
105
108
|
is the operator's request, and summarizing it silently would degrade planning
|
|
106
109
|
quality precisely when the input is richest — so there is no elision path to
|
|
107
110
|
fall back on. Raising the ceiling needs a measured justification.
|
|
@@ -324,9 +324,6 @@
|
|
|
324
324
|
"riskHeuristics": {
|
|
325
325
|
"$ref": "#/$defs/listOrExtenderOfStrings"
|
|
326
326
|
},
|
|
327
|
-
"codebaseSnapshot": {
|
|
328
|
-
"$ref": "#/$defs/codebaseSnapshot"
|
|
329
|
-
},
|
|
330
327
|
"complexityGate": {
|
|
331
328
|
"type": "object",
|
|
332
329
|
"description": "Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.",
|
|
@@ -392,34 +389,6 @@
|
|
|
392
389
|
},
|
|
393
390
|
"additionalProperties": false
|
|
394
391
|
},
|
|
395
|
-
"codebaseSnapshot": {
|
|
396
|
-
"type": "object",
|
|
397
|
-
"properties": {
|
|
398
|
-
"tier": {
|
|
399
|
-
"type": "string",
|
|
400
|
-
"enum": ["skinny", "medium"]
|
|
401
|
-
},
|
|
402
|
-
"include": {
|
|
403
|
-
"type": "array",
|
|
404
|
-
"items": {
|
|
405
|
-
"type": "string",
|
|
406
|
-
"minLength": 1
|
|
407
|
-
}
|
|
408
|
-
},
|
|
409
|
-
"exclude": {
|
|
410
|
-
"type": "array",
|
|
411
|
-
"items": {
|
|
412
|
-
"type": "string",
|
|
413
|
-
"minLength": 1
|
|
414
|
-
}
|
|
415
|
-
},
|
|
416
|
-
"recentCommitWindow": {
|
|
417
|
-
"type": "integer",
|
|
418
|
-
"minimum": 1
|
|
419
|
-
}
|
|
420
|
-
},
|
|
421
|
-
"additionalProperties": false
|
|
422
|
-
},
|
|
423
392
|
"execution": {
|
|
424
393
|
"type": "object",
|
|
425
394
|
"properties": {
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
// a retired token is always a non-zero exit even if a stale workflow
|
|
26
26
|
// file happens to exist.
|
|
27
27
|
//
|
|
28
|
+
// 4. Story #4801 — every relative link originating under `.agents/**`
|
|
29
|
+
// resolves to a target that still exists once the tree is materialized
|
|
30
|
+
// into a *consumer* project. See `escapesPayload` for the boundary rule.
|
|
31
|
+
//
|
|
28
32
|
// Exit codes:
|
|
29
33
|
// 0 every link and slash-command token resolves cleanly.
|
|
30
34
|
// 1 at least one violation; details are written to stderr (file:line).
|
|
@@ -35,6 +39,8 @@
|
|
|
35
39
|
import fs from 'node:fs';
|
|
36
40
|
import path from 'node:path';
|
|
37
41
|
import { fileURLToPath } from 'node:url';
|
|
42
|
+
import { minimatch } from 'minimatch';
|
|
43
|
+
import { parseStandardCliArgs } from './lib/cli/standard-args.js';
|
|
38
44
|
import { runAsCli } from './lib/cli-utils.js';
|
|
39
45
|
import { Logger } from './lib/Logger.js';
|
|
40
46
|
|
|
@@ -124,6 +130,63 @@ export const SLASH_ALLOWLIST = new Set([
|
|
|
124
130
|
'main',
|
|
125
131
|
]);
|
|
126
132
|
|
|
133
|
+
// --- Payload boundary (Story #4801) ----------------------------------------
|
|
134
|
+
|
|
135
|
+
// `mandrel sync` materializes ONLY the package's `.agents/` payload into a
|
|
136
|
+
// consumer's project, at `<projectRoot>/.agents` (see `lib/cli/sync.js`:
|
|
137
|
+
// `destRoot = path.join(projectRoot, '.agents')`). `bin/` and `lib/` ship
|
|
138
|
+
// inside the npm tarball but stay under `node_modules/mandrel/`, and the
|
|
139
|
+
// framework's own `tests/`, `docs/` (bar the CHANGELOG) and `.claude/` trees
|
|
140
|
+
// ship nowhere at all. So a relative link that escapes `.agents/` resolves
|
|
141
|
+
// cleanly in THIS repo and dangles in every consumer — which is exactly why
|
|
142
|
+
// the checker cannot catch this class by `fs.existsSync` alone.
|
|
143
|
+
//
|
|
144
|
+
// This is why the boundary is `.agents/` and NOT `package.json#files`: the
|
|
145
|
+
// latter lists `lib/` and `bin/`, which are packaged but never materialized
|
|
146
|
+
// at a consumer's repo root.
|
|
147
|
+
export const MATERIALIZED_ROOT = '.agents';
|
|
148
|
+
|
|
149
|
+
// Repo-root-relative paths OUTSIDE `.agents/` that a Mandrel *consumer*
|
|
150
|
+
// legitimately owns, so a doc under `.agents/**` may still link to them.
|
|
151
|
+
// Deliberately explicit rather than pattern-derived: whether a given repo-root
|
|
152
|
+
// path is consumer-owned or framework-only is a judgment per path, not a rule.
|
|
153
|
+
// A new escaping link fails closed until it is justified and added here.
|
|
154
|
+
export const CONSUMER_OWNED_PATHS = new Set([
|
|
155
|
+
'package.json',
|
|
156
|
+
'.agentrc.json',
|
|
157
|
+
'.c8rc.cjs',
|
|
158
|
+
'docs/architecture.md',
|
|
159
|
+
'docs/decisions.md',
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
// Directory prefixes (repo-root-relative, trailing slash) whose whole subtree
|
|
163
|
+
// is consumer-owned.
|
|
164
|
+
export const CONSUMER_OWNED_PREFIXES = Object.freeze(['baselines/']);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when `relTarget` is unreachable from a materialized consumer tree.
|
|
168
|
+
*
|
|
169
|
+
* Only links whose SOURCE lives under `.agents/**` are subject to the rule —
|
|
170
|
+
* `docs/**` is framework-repo-only, ships nowhere, and keeps today's
|
|
171
|
+
* existence-only semantics.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} relFile repo-relative POSIX path of the linking document
|
|
174
|
+
* @param {string} relTarget repo-relative POSIX path the link resolves to
|
|
175
|
+
*/
|
|
176
|
+
export function escapesPayload(relFile, relTarget) {
|
|
177
|
+
if (!relFile.startsWith(`${MATERIALIZED_ROOT}/`)) return false;
|
|
178
|
+
if (
|
|
179
|
+
relTarget === MATERIALIZED_ROOT ||
|
|
180
|
+
relTarget.startsWith(`${MATERIALIZED_ROOT}/`)
|
|
181
|
+
) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
if (CONSUMER_OWNED_PATHS.has(relTarget)) return false;
|
|
185
|
+
if (CONSUMER_OWNED_PREFIXES.some((p) => relTarget.startsWith(p)))
|
|
186
|
+
return false;
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
127
190
|
// --- File discovery --------------------------------------------------------
|
|
128
191
|
|
|
129
192
|
function isExcludedRelPath(relPath) {
|
|
@@ -150,14 +213,28 @@ function walkMarkdown(dirAbs, repoRoot, out) {
|
|
|
150
213
|
}
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
|
|
216
|
+
/**
|
|
217
|
+
* Collect every non-excluded `*.md` under each `scanRoots` entry.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} rootAbs absolute repo root
|
|
220
|
+
* @param {string[]} scanRoots repo-relative subtrees to walk
|
|
221
|
+
* @param {string[]} [exclude] minimatch globs; a repo-relative POSIX path
|
|
222
|
+
* matching any of them is dropped from the scan
|
|
223
|
+
*/
|
|
224
|
+
export function discoverMarkdown(rootAbs, scanRoots, exclude = []) {
|
|
154
225
|
const out = [];
|
|
155
226
|
for (const sub of scanRoots) {
|
|
156
227
|
const subAbs = path.join(rootAbs, sub);
|
|
157
228
|
if (fs.existsSync(subAbs)) walkMarkdown(subAbs, rootAbs, out);
|
|
158
229
|
}
|
|
159
|
-
|
|
160
|
-
|
|
230
|
+
const filtered = exclude.length
|
|
231
|
+
? out.filter((abs) => {
|
|
232
|
+
const rel = path.relative(rootAbs, abs).split(path.sep).join('/');
|
|
233
|
+
return !exclude.some((g) => minimatch(rel, g, { dot: true }));
|
|
234
|
+
})
|
|
235
|
+
: out;
|
|
236
|
+
filtered.sort();
|
|
237
|
+
return filtered;
|
|
161
238
|
}
|
|
162
239
|
|
|
163
240
|
// --- Region masking --------------------------------------------------------
|
|
@@ -326,6 +403,26 @@ export function checkFile(absPath, repoRoot) {
|
|
|
326
403
|
} else {
|
|
327
404
|
resolved = path.resolve(fileDir, pathOnly);
|
|
328
405
|
}
|
|
406
|
+
// Payload boundary (Story #4801) takes precedence over existence: a link
|
|
407
|
+
// that escapes the materialized tree is a defect even when the target
|
|
408
|
+
// exists here, and reporting both kinds for one link would double-count.
|
|
409
|
+
const relTarget = path
|
|
410
|
+
.relative(repoRoot, resolved)
|
|
411
|
+
.split(path.sep)
|
|
412
|
+
.join('/');
|
|
413
|
+
if (escapesPayload(relFile, relTarget)) {
|
|
414
|
+
violations.push({
|
|
415
|
+
file: relFile,
|
|
416
|
+
line,
|
|
417
|
+
kind: 'payload-boundary',
|
|
418
|
+
message:
|
|
419
|
+
`link escapes the materialized payload: ${target} → ${relTarget}. ` +
|
|
420
|
+
`Only '${MATERIALIZED_ROOT}/' is materialized into a consumer project, ` +
|
|
421
|
+
'so this resolves here but dangles for every consumer. Use an absolute ' +
|
|
422
|
+
'GitHub URL or a non-link code span.',
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
329
426
|
if (!fs.existsSync(resolved)) {
|
|
330
427
|
violations.push({
|
|
331
428
|
file: relFile,
|
|
@@ -377,6 +474,8 @@ export function checkFile(absPath, repoRoot) {
|
|
|
377
474
|
|
|
378
475
|
// --- Public entry point ----------------------------------------------------
|
|
379
476
|
|
|
477
|
+
export const DEFAULT_SCAN_ROOTS = Object.freeze(['docs', '.agents']);
|
|
478
|
+
|
|
380
479
|
/**
|
|
381
480
|
* Run the checker programmatically. Returns `{ exitCode, violations }`.
|
|
382
481
|
* `exitCode` is 0 when every doc is clean, 1 otherwise.
|
|
@@ -384,11 +483,13 @@ export function checkFile(absPath, repoRoot) {
|
|
|
384
483
|
* @param {object} [options]
|
|
385
484
|
* @param {string} [options.repoRoot] Defaults to the framework repo root.
|
|
386
485
|
* @param {string[]} [options.scanRoots] Defaults to `['docs', '.agents']`.
|
|
486
|
+
* @param {string[]} [options.exclude] minimatch globs dropped from the scan.
|
|
387
487
|
*/
|
|
388
488
|
export function runCheck(options = {}) {
|
|
389
489
|
const repoRoot = options.repoRoot ?? REPO_ROOT;
|
|
390
|
-
const scanRoots = options.scanRoots ?? [
|
|
391
|
-
const
|
|
490
|
+
const scanRoots = options.scanRoots ?? [...DEFAULT_SCAN_ROOTS];
|
|
491
|
+
const exclude = options.exclude ?? [];
|
|
492
|
+
const files = discoverMarkdown(repoRoot, scanRoots, exclude);
|
|
392
493
|
const violations = [];
|
|
393
494
|
for (const abs of files) {
|
|
394
495
|
const fileViolations = checkFile(abs, repoRoot);
|
|
@@ -405,8 +506,28 @@ function formatViolation(v) {
|
|
|
405
506
|
return `${v.file}:${v.line}: [${v.kind}] ${v.message}`;
|
|
406
507
|
}
|
|
407
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Translate argv into `runCheck` options. Repeatable `--scan-root` replaces
|
|
511
|
+
* the default scan set entirely; repeatable `--exclude` filters whatever was
|
|
512
|
+
* scanned. Absent flags reproduce the pre-#4801 defaults exactly.
|
|
513
|
+
*/
|
|
514
|
+
export function parseArgs(argv) {
|
|
515
|
+
const { values } = parseStandardCliArgs({
|
|
516
|
+
argv,
|
|
517
|
+
extras: {
|
|
518
|
+
'scan-root': { type: 'string-multi', alias: 'scanRoot' },
|
|
519
|
+
exclude: { type: 'string-multi', alias: 'exclude' },
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
const scanRoots = values.scanRoot?.length
|
|
523
|
+
? values.scanRoot
|
|
524
|
+
: [...DEFAULT_SCAN_ROOTS];
|
|
525
|
+
return { scanRoots, exclude: values.exclude ?? [] };
|
|
526
|
+
}
|
|
527
|
+
|
|
408
528
|
async function main() {
|
|
409
|
-
const
|
|
529
|
+
const { scanRoots, exclude } = parseArgs(process.argv.slice(2));
|
|
530
|
+
const result = runCheck({ scanRoots, exclude });
|
|
410
531
|
if (result.violations.length === 0) {
|
|
411
532
|
Logger.info(
|
|
412
533
|
`[check-doc-links] OK — scanned ${result.scanned} active markdown file(s); no violations.`,
|
|
@@ -426,11 +547,22 @@ async function main() {
|
|
|
426
547
|
runAsCli(import.meta.url, main, {
|
|
427
548
|
source: 'check-doc-links',
|
|
428
549
|
usage: {
|
|
429
|
-
invocation:
|
|
550
|
+
invocation:
|
|
551
|
+
'node .agents/scripts/check-doc-links.js [--scan-root <path>] [--exclude <glob>]',
|
|
430
552
|
summary:
|
|
431
|
-
'Validate every relative Markdown link and /slash-command token across docs/ and .agents/,
|
|
432
|
-
flags: [
|
|
553
|
+
'Validate every relative Markdown link and /slash-command token across docs/ and .agents/, reject mentions of retired commands, and reject links that escape the materialized .agents/ payload.',
|
|
554
|
+
flags: [
|
|
555
|
+
[
|
|
556
|
+
'--scan-root <path>',
|
|
557
|
+
'Repeatable. Repo-relative subtree to scan. Replaces the default set (docs, .agents).',
|
|
558
|
+
],
|
|
559
|
+
[
|
|
560
|
+
'--exclude <glob>',
|
|
561
|
+
'Repeatable. minimatch glob; matching files are dropped from the scan.',
|
|
562
|
+
],
|
|
563
|
+
],
|
|
433
564
|
notes: [
|
|
565
|
+
'Consumers materialize only .agents/, so a relative link from .agents/**\nto a framework-repo-only path (tests/, lib/, .claude/, framework docs)\nis reported as a payload-boundary violation even though it resolves here.',
|
|
434
566
|
'Exit codes:\n 0 every link and command token resolves\n 1 at least one violation (file:line on stderr)',
|
|
435
567
|
],
|
|
436
568
|
},
|
|
@@ -15,16 +15,41 @@
|
|
|
15
15
|
* per-process scratch dir. This script is the regression guard that keeps
|
|
16
16
|
* the fix honest, plus a local cleanup mode for the accumulated noise:
|
|
17
17
|
*
|
|
18
|
+
* The guard covers two distinct temp roots, and conflating them is how the
|
|
19
|
+
* second one went unmeasured for so long:
|
|
20
|
+
*
|
|
21
|
+
* 1. The repo's own `temp/` telemetry tree — the original dimension above.
|
|
22
|
+
* 2. The **OS temp root** (Story #4808). The redirect in (1) sends stray
|
|
23
|
+
* writes into `os.tmpdir()` scratch dirs, and nothing ever reaped them:
|
|
24
|
+
* the remedy for (1) became the largest single leaker into (2). Since
|
|
25
|
+
* the damaging axis there is entry *count*, the suite now nests every
|
|
26
|
+
* managed dir inside one per-process `mandrel-suite-*` root
|
|
27
|
+
* (`lib/test-temp.js`) and reaps it, and this guard asserts that no
|
|
28
|
+
* such root survives a run.
|
|
29
|
+
*
|
|
18
30
|
* --snapshot Record a fingerprint (size + sha256) of every stream
|
|
19
|
-
* file under `temp
|
|
20
|
-
*
|
|
31
|
+
* file under `temp/`, plus the `mandrel-suite-*` roots
|
|
32
|
+
* already present in the OS temp root, to the snapshot
|
|
33
|
+
* baseline. Run this before the suite.
|
|
21
34
|
* --assert Re-scan and fail if any stream file was added or grew
|
|
22
|
-
* relative to the snapshot
|
|
23
|
-
*
|
|
35
|
+
* relative to the snapshot, or if a suite root appeared
|
|
36
|
+
* and survived. Run this after the suite. A missing
|
|
37
|
+
* snapshot is a hard failure ("snapshot missing
|
|
24
38
|
* — guard cannot attest"), never a silent re-baseline:
|
|
25
39
|
* the baseline lives *outside* the protected `temp/`
|
|
26
40
|
* tree (Story #4711), so a test wiping `temp/` can no
|
|
27
41
|
* longer destroy the baseline and fail the guard open.
|
|
42
|
+
* Recording pre-existing suite roots (rather than
|
|
43
|
+
* asserting an empty set) is what keeps a concurrent
|
|
44
|
+
* suite in another checkout from failing this one.
|
|
45
|
+
* --lint-globs <g> Comma-separated repo-relative globs to scan for test
|
|
46
|
+
* files that call `mkdtemp` against `os.tmpdir()`
|
|
47
|
+
* directly instead of going through `makeTempDir`.
|
|
48
|
+
* **Off unless passed**: this script ships in the
|
|
49
|
+
* materialized `.agents/` payload and a consumer's
|
|
50
|
+
* tests are none of this rule's business. A line (or
|
|
51
|
+
* the line above it) carrying `test-temp-allow` opts
|
|
52
|
+
* out.
|
|
28
53
|
* --baseline <path> Explicit snapshot-baseline path (CI sets this to a
|
|
29
54
|
* runner-temp path). Defaults to an OS scratch location
|
|
30
55
|
* keyed by the resolved repo root. Refused when it
|
|
@@ -53,6 +78,12 @@ import os from 'node:os';
|
|
|
53
78
|
import path from 'node:path';
|
|
54
79
|
import { fileURLToPath } from 'node:url';
|
|
55
80
|
import { runAsCli } from './lib/cli-utils.js';
|
|
81
|
+
import {
|
|
82
|
+
findRawTmpdirMkdtemp,
|
|
83
|
+
listSuiteTempRoots,
|
|
84
|
+
SUITE_ROOTS_KEY,
|
|
85
|
+
survivingSuiteTempRoots,
|
|
86
|
+
} from './lib/test-temp.js';
|
|
56
87
|
|
|
57
88
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
58
89
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -193,17 +224,27 @@ export function buildManifest(tempDir) {
|
|
|
193
224
|
* `defaultBaselinePath` — never inside `temp/`).
|
|
194
225
|
* @param {string} repoRoot
|
|
195
226
|
* @param {string} [baselinePath]
|
|
196
|
-
* @
|
|
227
|
+
* @param {{ tmpDir?: string }} [deps] Injectable OS temp root for tests.
|
|
228
|
+
* @returns {{ snapshotPath: string, count: number, suiteRoots: number }}
|
|
197
229
|
*/
|
|
198
|
-
export function writeSnapshot(repoRoot, baselinePath) {
|
|
230
|
+
export function writeSnapshot(repoRoot, baselinePath, { tmpDir } = {}) {
|
|
199
231
|
const snapshotPath = checkedBaselinePath(
|
|
200
232
|
repoRoot,
|
|
201
233
|
baselinePath ?? defaultBaselinePath(repoRoot),
|
|
202
234
|
);
|
|
203
235
|
const manifest = buildManifest(tempDirFor(repoRoot));
|
|
236
|
+
const count = Object.keys(manifest).length;
|
|
237
|
+
// Reserved key: stream entries are always `*.ndjson` relative paths, so
|
|
238
|
+
// this cannot shadow one, and `diffAgainstSnapshot` only ever looks up
|
|
239
|
+
// keys derived from the tree it just walked.
|
|
240
|
+
manifest[SUITE_ROOTS_KEY] = listSuiteTempRoots(tmpDir ?? os.tmpdir());
|
|
204
241
|
mkdirSync(path.dirname(snapshotPath), { recursive: true });
|
|
205
242
|
writeFileSync(snapshotPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
206
|
-
return {
|
|
243
|
+
return {
|
|
244
|
+
snapshotPath,
|
|
245
|
+
count,
|
|
246
|
+
suiteRoots: manifest[SUITE_ROOTS_KEY].length,
|
|
247
|
+
};
|
|
207
248
|
}
|
|
208
249
|
|
|
209
250
|
/**
|
|
@@ -331,7 +372,7 @@ export function cleanFixtureDirs({
|
|
|
331
372
|
/**
|
|
332
373
|
* Parse the CLI argv into a normalised options object.
|
|
333
374
|
* @param {string[]} argv
|
|
334
|
-
* @returns {{ mode: 'snapshot'|'assert'|'clean', apply: boolean, ids: number[]|null, repoRoot: string, baseline: string|null }}
|
|
375
|
+
* @returns {{ mode: 'snapshot'|'assert'|'clean', apply: boolean, ids: number[]|null, repoRoot: string, baseline: string|null, lintGlobs: string[] }}
|
|
335
376
|
*/
|
|
336
377
|
export function parseArgv(argv) {
|
|
337
378
|
let mode = 'assert';
|
|
@@ -339,13 +380,20 @@ export function parseArgv(argv) {
|
|
|
339
380
|
let ids = null;
|
|
340
381
|
let repoRoot = REPO_ROOT;
|
|
341
382
|
let baseline = null;
|
|
383
|
+
let lintGlobs = [];
|
|
342
384
|
for (let i = 0; i < argv.length; i += 1) {
|
|
343
385
|
const arg = argv[i];
|
|
344
386
|
if (arg === '--snapshot') mode = 'snapshot';
|
|
345
387
|
else if (arg === '--assert') mode = 'assert';
|
|
346
388
|
else if (arg === '--clean') mode = 'clean';
|
|
347
389
|
else if (arg === '--yes') apply = true;
|
|
348
|
-
else if (arg === '--
|
|
390
|
+
else if (arg === '--lint-globs') {
|
|
391
|
+
i += 1;
|
|
392
|
+
lintGlobs = String(argv[i] ?? '')
|
|
393
|
+
.split(',')
|
|
394
|
+
.map((s) => s.trim())
|
|
395
|
+
.filter(Boolean);
|
|
396
|
+
} else if (arg === '--ids') {
|
|
349
397
|
i += 1;
|
|
350
398
|
ids = String(argv[i] ?? '')
|
|
351
399
|
.split(',')
|
|
@@ -359,7 +407,7 @@ export function parseArgv(argv) {
|
|
|
359
407
|
baseline = path.resolve(String(argv[i] ?? '.'));
|
|
360
408
|
}
|
|
361
409
|
}
|
|
362
|
-
return { mode, apply, ids, repoRoot, baseline };
|
|
410
|
+
return { mode, apply, ids, repoRoot, baseline, lintGlobs };
|
|
363
411
|
}
|
|
364
412
|
|
|
365
413
|
/**
|
|
@@ -369,14 +417,23 @@ export function parseArgv(argv) {
|
|
|
369
417
|
*
|
|
370
418
|
* @param {ReturnType<typeof parseArgv>} opts
|
|
371
419
|
* @param {(line: string) => void} [log]
|
|
420
|
+
* @param {{ tmpDir?: string }} [deps] Injectable OS temp root for tests.
|
|
372
421
|
* @returns {number}
|
|
373
422
|
*/
|
|
374
|
-
export function runHygiene(
|
|
375
|
-
|
|
423
|
+
export function runHygiene(
|
|
424
|
+
opts,
|
|
425
|
+
log = (l) => process.stdout.write(`${l}\n`),
|
|
426
|
+
{ tmpDir = os.tmpdir() } = {},
|
|
427
|
+
) {
|
|
428
|
+
const { mode, apply, ids, repoRoot, baseline = null, lintGlobs = [] } = opts;
|
|
376
429
|
if (mode === 'snapshot') {
|
|
377
|
-
const { snapshotPath, count } = writeSnapshot(
|
|
430
|
+
const { snapshotPath, count, suiteRoots } = writeSnapshot(
|
|
431
|
+
repoRoot,
|
|
432
|
+
baseline,
|
|
433
|
+
{ tmpDir },
|
|
434
|
+
);
|
|
378
435
|
log(
|
|
379
|
-
`[test-temp-hygiene] snapshot recorded (${count} stream file(s)) → ${snapshotPath}`,
|
|
436
|
+
`[test-temp-hygiene] snapshot recorded (${count} stream file(s), ${suiteRoots} pre-existing suite root(s)) → ${snapshotPath}`,
|
|
380
437
|
);
|
|
381
438
|
return 0;
|
|
382
439
|
}
|
|
@@ -409,6 +466,26 @@ export function runHygiene(opts, log = (l) => process.stdout.write(`${l}\n`)) {
|
|
|
409
466
|
);
|
|
410
467
|
return 1;
|
|
411
468
|
}
|
|
469
|
+
// Every dimension runs and reports; a failure in one must not hide a
|
|
470
|
+
// failure in another, so the exit code is the max rather than an
|
|
471
|
+
// early return.
|
|
472
|
+
const codes = [
|
|
473
|
+
assertStreamTree(repoRoot, snapshot, log),
|
|
474
|
+
assertNoSurvivingSuiteRoots(snapshot, log, tmpDir),
|
|
475
|
+
assertNoRawTmpdirMkdtemp(repoRoot, lintGlobs, log),
|
|
476
|
+
];
|
|
477
|
+
return Math.max(...codes);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Dimension 1 — the repo's own `temp/` telemetry tree (Story #4696).
|
|
482
|
+
*
|
|
483
|
+
* @param {string} repoRoot
|
|
484
|
+
* @param {Record<string, unknown>} snapshot
|
|
485
|
+
* @param {(line: string) => void} log
|
|
486
|
+
* @returns {number} exit code
|
|
487
|
+
*/
|
|
488
|
+
function assertStreamTree(repoRoot, snapshot, log) {
|
|
412
489
|
const { added, changed } = diffAgainstSnapshot(
|
|
413
490
|
tempDirFor(repoRoot),
|
|
414
491
|
snapshot,
|
|
@@ -428,6 +505,68 @@ export function runHygiene(opts, log = (l) => process.stdout.write(`${l}\n`)) {
|
|
|
428
505
|
return 1;
|
|
429
506
|
}
|
|
430
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Dimension 2 — the OS temp root (Story #4808). Fails when a suite root
|
|
510
|
+
* appeared since the snapshot and is still on disk, which means the run
|
|
511
|
+
* created it and never reaped it.
|
|
512
|
+
*
|
|
513
|
+
* @param {Record<string, unknown>} snapshot
|
|
514
|
+
* @param {(line: string) => void} log
|
|
515
|
+
* @param {string} tmpDir
|
|
516
|
+
* @returns {number} exit code
|
|
517
|
+
*/
|
|
518
|
+
function assertNoSurvivingSuiteRoots(snapshot, log, tmpDir) {
|
|
519
|
+
const before = Array.isArray(snapshot[SUITE_ROOTS_KEY])
|
|
520
|
+
? snapshot[SUITE_ROOTS_KEY]
|
|
521
|
+
: [];
|
|
522
|
+
const surviving = survivingSuiteTempRoots(tmpDir, before);
|
|
523
|
+
if (surviving.length === 0) {
|
|
524
|
+
log('[test-temp-hygiene] OK — no suite temp roots survived the run.');
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
log(
|
|
528
|
+
`[test-temp-hygiene] FAIL — ${surviving.length} suite temp root(s) survived in ${tmpDir}:`,
|
|
529
|
+
);
|
|
530
|
+
for (const name of surviving) log(` + leaked ${name}`);
|
|
531
|
+
log(
|
|
532
|
+
'[test-temp-hygiene] a process minted a suite root and exited without reaping it. Do not delete these by hand — find the writer that bypassed makeTempDir().',
|
|
533
|
+
);
|
|
534
|
+
return 1;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Dimension 3 — the static backstop (Story #4808). Skipped, and reported
|
|
539
|
+
* as skipped, unless the caller passed `--lint-globs`.
|
|
540
|
+
*
|
|
541
|
+
* @param {string} repoRoot
|
|
542
|
+
* @param {string[]} globs
|
|
543
|
+
* @param {(line: string) => void} log
|
|
544
|
+
* @returns {number} exit code
|
|
545
|
+
*/
|
|
546
|
+
function assertNoRawTmpdirMkdtemp(repoRoot, globs, log) {
|
|
547
|
+
if (!globs || globs.length === 0) {
|
|
548
|
+
log(
|
|
549
|
+
'[test-temp-hygiene] SKIP — raw-tmpdir lint not requested (pass --lint-globs to enable).',
|
|
550
|
+
);
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
const findings = findRawTmpdirMkdtemp(repoRoot, globs);
|
|
554
|
+
if (findings.length === 0) {
|
|
555
|
+
log(
|
|
556
|
+
'[test-temp-hygiene] OK — no test file mints OS temp dirs outside makeTempDir().',
|
|
557
|
+
);
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
log(
|
|
561
|
+
`[test-temp-hygiene] FAIL — ${findings.length} raw os.tmpdir() mkdtemp call(s) in test files:`,
|
|
562
|
+
);
|
|
563
|
+
for (const f of findings) log(` ${f.file}:${f.line} ${f.text}`);
|
|
564
|
+
log(
|
|
565
|
+
"[test-temp-hygiene] use makeTempDir() from .agents/scripts/lib/test-temp.js so teardown is registered, or mark the line 'test-temp-allow: <reason>' when the real root is genuinely required.",
|
|
566
|
+
);
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
|
|
431
570
|
runAsCli(
|
|
432
571
|
import.meta.url,
|
|
433
572
|
async () => {
|