@aperant/framework 0.19.0 → 0.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/CHANGELOG.md +109 -0
- package/agents/apt-executor.md +6 -0
- package/agents/apt-planner.md +3 -2
- package/bin/features-reconcile-check.mjs +77 -0
- package/dist/cli/artifacts/self-stage.d.mts.map +1 -1
- package/dist/cli/artifacts/self-stage.mjs +2 -1
- package/dist/cli/artifacts/self-stage.mjs.map +1 -1
- package/dist/cli/cli-wrappers/features-reconcile.d.mts +2 -0
- package/dist/cli/cli-wrappers/features-reconcile.d.mts.map +1 -0
- package/dist/cli/cli-wrappers/features-reconcile.mjs +9 -0
- package/dist/cli/cli-wrappers/features-reconcile.mjs.map +1 -0
- package/dist/cli/commands/features-reconcile.d.mts +2 -0
- package/dist/cli/commands/features-reconcile.d.mts.map +1 -0
- package/dist/cli/commands/features-reconcile.mjs +143 -0
- package/dist/cli/commands/features-reconcile.mjs.map +1 -0
- package/dist/cli/commands/health-check.d.mts +2 -2
- package/dist/cli/commands/health-check.mjs +2 -2
- package/dist/cli/dispatch.mjs +2 -2
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/features/area-shape.d.mts +11 -0
- package/dist/cli/features/area-shape.d.mts.map +1 -0
- package/dist/cli/features/area-shape.mjs +141 -0
- package/dist/cli/features/area-shape.mjs.map +1 -0
- package/dist/cli/features/delta.d.mts +78 -0
- package/dist/cli/features/delta.d.mts.map +1 -0
- package/dist/cli/features/delta.mjs +591 -0
- package/dist/cli/features/delta.mjs.map +1 -0
- package/dist/cli/features/reconcile.d.mts +236 -0
- package/dist/cli/features/reconcile.d.mts.map +1 -0
- package/dist/cli/features/reconcile.mjs +1126 -0
- package/dist/cli/features/reconcile.mjs.map +1 -0
- package/dist/cli/features/surface-map.d.mts +63 -0
- package/dist/cli/features/surface-map.d.mts.map +1 -0
- package/dist/cli/features/surface-map.mjs +191 -0
- package/dist/cli/features/surface-map.mjs.map +1 -0
- package/dist/cli/{commands/features-audit.d.mts → features/write-root.d.mts} +4 -3
- package/dist/cli/features/write-root.d.mts.map +1 -0
- package/dist/cli/features/write-root.mjs +137 -0
- package/dist/cli/features/write-root.mjs.map +1 -0
- package/dist/cli/gate/gates/features-reconciled.d.mts +36 -0
- package/dist/cli/gate/gates/features-reconciled.d.mts.map +1 -0
- package/dist/cli/gate/gates/features-reconciled.mjs +67 -0
- package/dist/cli/gate/gates/features-reconciled.mjs.map +1 -0
- package/dist/cli/gate/gates/index.mjs +1 -0
- package/dist/cli/gate/gates/index.mjs.map +1 -1
- package/dist/cli/help.mjs +1 -1
- package/dist/cli/release-notes/ship-autodraft.mjs +2 -2
- package/dist/cli/variant/gallery.d.mts.map +1 -1
- package/dist/cli/variant/gallery.mjs +7 -3
- package/dist/cli/variant/gallery.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/apt-executor.md +6 -0
- package/dist/plugin/agents/apt-planner.md +3 -2
- package/dist/plugin/skills/apt-close-task/SKILL.md +2 -2
- package/dist/plugin/skills/apt-debug/SKILL.md +8 -0
- package/dist/plugin/skills/apt-execute/SKILL.md +35 -26
- package/dist/plugin/skills/apt-mockup/SKILL.md +1 -1
- package/dist/plugin/skills/apt-quick/SKILL.md +13 -11
- package/dist/plugin/skills/apt-review/SKILL.md +14 -11
- package/dist/plugin/skills/apt-run/SKILL.md +9 -3
- package/dist/plugin/skills/apt-ship/SKILL.md +27 -5
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +2 -0
- package/dist/schemas/feature-registry.d.ts.map +1 -1
- package/dist/schemas/feature-registry.js +34 -3
- package/dist/schemas/feature-registry.js.map +1 -1
- package/dist/types/config.d.ts +8 -5
- package/dist/types/config.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/apt-close-task/SKILL.md +2 -2
- package/skills/apt-debug/SKILL.md +8 -0
- package/skills/apt-execute/SKILL.md +35 -26
- package/skills/apt-mockup/SKILL.md +1 -1
- package/skills/apt-quick/SKILL.md +13 -11
- package/skills/apt-review/SKILL.md +14 -11
- package/skills/apt-run/SKILL.md +9 -3
- package/skills/apt-ship/SKILL.md +27 -5
- package/skills/apt-watch-ci/SKILL.md +2 -0
- package/src/cli/artifacts/self-stage.mjs +2 -1
- package/src/cli/cli-wrappers/features-reconcile.mjs +9 -0
- package/src/cli/commands/features-reconcile.mjs +157 -0
- package/src/cli/commands/health-check.mjs +2 -2
- package/src/cli/dispatch.mjs +2 -2
- package/src/cli/features/area-shape.mjs +140 -0
- package/src/cli/features/delta.mjs +625 -0
- package/src/cli/features/reconcile.mjs +1169 -0
- package/src/cli/features/surface-map.mjs +192 -0
- package/src/cli/features/write-root.mjs +140 -0
- package/src/cli/gate/gates/features-reconciled.mjs +70 -0
- package/src/cli/gate/gates/index.mjs +1 -0
- package/src/cli/help.mjs +1 -1
- package/src/cli/release-notes/ship-autodraft.mjs +2 -2
- package/src/cli/variant/gallery.mjs +7 -3
- package/templates/aperant-claude-md-appendix.md +2 -2
- package/workflows/scan-features.md +17 -11
- package/dist/cli/cli-wrappers/features-audit.d.mts +0 -2
- package/dist/cli/cli-wrappers/features-audit.d.mts.map +0 -1
- package/dist/cli/cli-wrappers/features-audit.mjs +0 -8
- package/dist/cli/cli-wrappers/features-audit.mjs.map +0 -1
- package/dist/cli/commands/features-audit.d.mts.map +0 -1
- package/dist/cli/commands/features-audit.mjs +0 -293
- package/dist/cli/commands/features-audit.mjs.map +0 -1
- package/dist/cli/features/registry-audit.d.mts +0 -56
- package/dist/cli/features/registry-audit.d.mts.map +0 -1
- package/dist/cli/features/registry-audit.mjs +0 -264
- package/dist/cli/features/registry-audit.mjs.map +0 -1
- package/src/cli/cli-wrappers/features-audit.mjs +0 -8
- package/src/cli/commands/features-audit.mjs +0 -302
- package/src/cli/features/registry-audit.mjs +0 -254
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* commands/features-audit.mjs — CLI entry point for the features-registry
|
|
3
|
-
* audit (C33).
|
|
4
|
-
*
|
|
5
|
-
* Modes:
|
|
6
|
-
* - report only (default): print `new_components`, `unregistered`, `stub_patches`
|
|
7
|
-
* - `--apply-stubs`: additionally write the stub patches to disk.
|
|
8
|
-
* This is what the /apt:execute post-step calls.
|
|
9
|
-
*
|
|
10
|
-
* Diff source precedence:
|
|
11
|
-
* 1. `--diff-files <a,b,c>` explicit CSV (highest priority)
|
|
12
|
-
* 2. `--since <ref>` → runs `git diff --name-only <ref>..HEAD`
|
|
13
|
-
* 3. fallback: `git diff HEAD --name-only` (current WIP)
|
|
14
|
-
*
|
|
15
|
-
* BUG-025b worktree-redirect: features-audit historically wrote to the
|
|
16
|
-
* invoking cwd's `.aperant/features/`, leaking generated `*.json` into
|
|
17
|
-
* main when the executor invoked it from a worktree-isolated task. The
|
|
18
|
-
* fix applies a 4-step resolution order (see resolveWriteRoot below):
|
|
19
|
-
* (1) cwd IS a worktree root → write there (current behaviour)
|
|
20
|
-
* (2) --task-id maps to an active non-terminal worktree task →
|
|
21
|
-
* redirect writes to that worktree's `.aperant/features/`
|
|
22
|
-
* (3) multiple active worktree-isolated tasks + no --task-id → error
|
|
23
|
-
* (4) `--no-worktree-redirect` opt-out preserves layer (1) behaviour
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { execFileSync, spawnSync } from 'node:child_process'
|
|
27
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
28
|
-
import { join, resolve, sep } from 'node:path'
|
|
29
|
-
import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs'
|
|
30
|
-
import { applyStubPatches, auditFeatureRegistry } from '../features/registry-audit.mjs'
|
|
31
|
-
import { parseFlags } from '../util/args.mjs'
|
|
32
|
-
import { err, ok } from '../util/result.mjs'
|
|
33
|
-
import { resolveMainRepoRoot } from '../util/state-paths.mjs'
|
|
34
|
-
|
|
35
|
-
const TERMINAL_PHASES = new Set(['completed', 'cancelled', 'closed', 'shipped', 'merged'])
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* BUG-025b — resolve where features-audit should write `*.json`.
|
|
39
|
-
* See module header for the 4-step order. Returns `{ writeRoot, redirect_notice }`
|
|
40
|
-
* on success or a synthetic error envelope (`{ error: { ... } }`) on the
|
|
41
|
-
* ambiguous-worktree case.
|
|
42
|
-
*
|
|
43
|
-
* @internal — exported for unit testing.
|
|
44
|
-
*
|
|
45
|
-
* @param {{projectDir: string, taskId?: string | null, noRedirect?: boolean}} args
|
|
46
|
-
* @returns {{writeRoot?: string, redirect_notice?: string | null, error?: {kind: string, message: string, active_worktree_tasks: string[]}}}
|
|
47
|
-
*/
|
|
48
|
-
export function resolveWriteRoot({ projectDir, taskId, noRedirect }) {
|
|
49
|
-
const root = resolve(projectDir)
|
|
50
|
-
if (noRedirect === true) {
|
|
51
|
-
return { writeRoot: root, redirect_notice: null }
|
|
52
|
-
}
|
|
53
|
-
// Layer (1): cwd IS a worktree root. Worktrees have `git rev-parse
|
|
54
|
-
// --git-dir` != `git rev-parse --git-common-dir`. Falling through to
|
|
55
|
-
// the existing behaviour writes to this worktree's .aperant/features.
|
|
56
|
-
// Fast path: git sets GIT_DIR and GIT_COMMON_DIR in the environment
|
|
57
|
-
// when running inside a worktree hook context — if both are set and
|
|
58
|
-
// differ, we are in a worktree. This env-var check is ONLY valid at
|
|
59
|
-
// Layer (1) where we ask "is this process in a worktree?"; it must
|
|
60
|
-
// NOT be used to validate an untrusted external path (Layer 2 below).
|
|
61
|
-
const envDir = process.env.GIT_DIR
|
|
62
|
-
const envCommonDir = process.env.GIT_COMMON_DIR
|
|
63
|
-
if (envDir && envCommonDir) {
|
|
64
|
-
if (resolve(envDir) !== resolve(envCommonDir)) {
|
|
65
|
-
return { writeRoot: root, redirect_notice: null }
|
|
66
|
-
}
|
|
67
|
-
} else if (isWorktreeRoot(root)) {
|
|
68
|
-
return { writeRoot: root, redirect_notice: null }
|
|
69
|
-
}
|
|
70
|
-
// Layer (2): --task-id maps to an active non-terminal worktree-isolated
|
|
71
|
-
// task → redirect.
|
|
72
|
-
const stateActive = readActiveTasks(root)
|
|
73
|
-
if (taskId) {
|
|
74
|
-
const task = stateActive[taskId]
|
|
75
|
-
if (
|
|
76
|
-
task &&
|
|
77
|
-
!TERMINAL_PHASES.has(task.lifecycle_phase) &&
|
|
78
|
-
typeof task.worktree_path === 'string' &&
|
|
79
|
-
existsSync(task.worktree_path)
|
|
80
|
-
) {
|
|
81
|
-
const resolvedWorktree = resolve(task.worktree_path)
|
|
82
|
-
const worktreesDir = resolve(root, '.aperant', 'worktrees') + sep
|
|
83
|
-
const isUnderProject = resolvedWorktree.startsWith(root + sep) || resolvedWorktree === root
|
|
84
|
-
const isUnderWorktrees = resolvedWorktree.startsWith(worktreesDir)
|
|
85
|
-
// Accept only paths under the project root, under .aperant/worktrees/,
|
|
86
|
-
// or confirmed as a real git worktree by git itself. Rejects arbitrary
|
|
87
|
-
// paths injected via a tampered state.json while preserving test
|
|
88
|
-
// fixtures that use ad-hoc worktree paths outside the project tree.
|
|
89
|
-
if (!isUnderProject && !isUnderWorktrees && !isWorktreeRoot(resolvedWorktree)) {
|
|
90
|
-
return { writeRoot: root, redirect_notice: null }
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
writeRoot: resolvedWorktree,
|
|
94
|
-
redirect_notice: `features-audit redirected writes to worktree ${resolvedWorktree} (--task-id ${taskId})`,
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Layer (3): multiple active worktree-isolated tasks AND no --task-id
|
|
99
|
-
// → actionable error. Single-worktree case is OK to default to root
|
|
100
|
-
// (the executor will set up the worktree's registry on first scan).
|
|
101
|
-
const activeWorktreeTasks = Object.entries(stateActive).filter(([_id, t]) => {
|
|
102
|
-
return (
|
|
103
|
-
t &&
|
|
104
|
-
!TERMINAL_PHASES.has(t.lifecycle_phase) &&
|
|
105
|
-
typeof t.worktree_path === 'string' &&
|
|
106
|
-
existsSync(t.worktree_path)
|
|
107
|
-
)
|
|
108
|
-
})
|
|
109
|
-
if (!taskId && activeWorktreeTasks.length > 1) {
|
|
110
|
-
return {
|
|
111
|
-
error: {
|
|
112
|
-
kind: 'ambiguous-worktree-context',
|
|
113
|
-
message: 'ambiguous worktree context; pass --task-id <id> or --no-worktree-redirect',
|
|
114
|
-
active_worktree_tasks: activeWorktreeTasks.map(([id]) => id),
|
|
115
|
-
},
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
// Default: current behaviour (write into cwd).
|
|
119
|
-
return { writeRoot: root, redirect_notice: null }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isWorktreeRoot(projectDir) {
|
|
123
|
-
try {
|
|
124
|
-
const dirRes = spawnSync('git', ['rev-parse', '--git-dir'], {
|
|
125
|
-
cwd: projectDir,
|
|
126
|
-
encoding: 'utf-8',
|
|
127
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
128
|
-
})
|
|
129
|
-
const commonRes = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
130
|
-
cwd: projectDir,
|
|
131
|
-
encoding: 'utf-8',
|
|
132
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
133
|
-
})
|
|
134
|
-
if (dirRes.status !== 0 || commonRes.status !== 0) return false
|
|
135
|
-
return (dirRes.stdout || '').trim() !== (commonRes.stdout || '').trim()
|
|
136
|
-
} catch {
|
|
137
|
-
return false
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function readActiveTasks(projectDir) {
|
|
142
|
-
// state-fork-fix: read the AUTHORITATIVE main board — readActiveTasks finds
|
|
143
|
-
// each task's worktree_path for the BUG-025b feature-write redirect, so it
|
|
144
|
-
// MUST read main or the redirect-target lookup itself forks. (The feature
|
|
145
|
-
// WRITE path / resolveWriteRoot stays untouched.)
|
|
146
|
-
const statePath = join(resolveMainRepoRoot(projectDir), '.aperant', 'state.json')
|
|
147
|
-
if (!existsSync(statePath)) return {}
|
|
148
|
-
try {
|
|
149
|
-
const parsed = JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
150
|
-
return parsed?.active_tasks ?? {}
|
|
151
|
-
} catch {
|
|
152
|
-
return {}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function cmdFeaturesAudit(projectDir, extraArgs) {
|
|
157
|
-
if (!projectDir) return err('Usage: apt-tools features-audit <project-dir> [flags]')
|
|
158
|
-
const root = resolve(projectDir)
|
|
159
|
-
const flags = parseFlags(extraArgs)
|
|
160
|
-
|
|
161
|
-
// BUG-025b: redirect writes to the active task's worktree when the
|
|
162
|
-
// caller is in a non-worktree cwd. See resolveWriteRoot for the
|
|
163
|
-
// 4-step resolution order.
|
|
164
|
-
const taskIdFlag = flags.get('task-id')
|
|
165
|
-
const taskId = typeof taskIdFlag === 'string' && taskIdFlag.trim() ? taskIdFlag.trim() : null
|
|
166
|
-
const noRedirect = flags.get('no-worktree-redirect') === true
|
|
167
|
-
const writeRootResult = resolveWriteRoot({ projectDir: root, taskId, noRedirect })
|
|
168
|
-
if (writeRootResult.error) {
|
|
169
|
-
return err(writeRootResult.error.message, 1, {
|
|
170
|
-
kind: writeRootResult.error.kind,
|
|
171
|
-
active_worktree_tasks: writeRootResult.error.active_worktree_tasks,
|
|
172
|
-
})
|
|
173
|
-
}
|
|
174
|
-
const writeRoot = writeRootResult.writeRoot
|
|
175
|
-
const redirectNotice = writeRootResult.redirect_notice
|
|
176
|
-
if (redirectNotice) {
|
|
177
|
-
// Surface the redirect once on stderr so the executor's log carries
|
|
178
|
-
// the audit trail without polluting stdout JSON.
|
|
179
|
-
process.stderr.write(`[features-audit] ${redirectNotice}\n`)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const diffFiles = resolveDiffFiles(root, flags)
|
|
183
|
-
const applyStubs = flags.has('apply-stubs')
|
|
184
|
-
const format = (flags.get('format') || 'json').toLowerCase()
|
|
185
|
-
|
|
186
|
-
const result = auditFeatureRegistry({ projectDir: writeRoot, diffFiles })
|
|
187
|
-
|
|
188
|
-
// Honor `.aperant/config.json.docs_policy`: only apply stubs for targets
|
|
189
|
-
// whose resolved policy is `auto-update`. This closes the skill-vs-code
|
|
190
|
-
// gap where `apt-execute` §3e-features promised `--apply-stubs` would
|
|
191
|
-
// no-op on surfaces the user opted out of. Patches whose target matches
|
|
192
|
-
// `skip` / `flag-drift` (or nothing) are surfaced in `stub_patches` but
|
|
193
|
-
// never written to disk.
|
|
194
|
-
let applied_patches = null
|
|
195
|
-
let skipped_patches = null
|
|
196
|
-
if (applyStubs && result.stub_patches.length > 0) {
|
|
197
|
-
const policy = loadDocsPolicy(root)
|
|
198
|
-
const eligible = []
|
|
199
|
-
const skipped = []
|
|
200
|
-
for (const patch of result.stub_patches) {
|
|
201
|
-
const policyValue = matchSurfaceToPolicy(patch.target, policy)
|
|
202
|
-
if (policyValue === 'auto-update') {
|
|
203
|
-
eligible.push(patch)
|
|
204
|
-
} else {
|
|
205
|
-
skipped.push({ target: patch.target, policy: policyValue })
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (eligible.length > 0) {
|
|
209
|
-
// BUG-025b: stubs are written to the resolved `writeRoot` (which
|
|
210
|
-
// may be the active task's worktree) rather than the invoking cwd.
|
|
211
|
-
applied_patches = applyStubPatches(writeRoot, eligible)
|
|
212
|
-
}
|
|
213
|
-
if (skipped.length > 0) {
|
|
214
|
-
skipped_patches = skipped
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (format === 'md') {
|
|
219
|
-
return ok({
|
|
220
|
-
status: 'ok',
|
|
221
|
-
command: 'features-audit',
|
|
222
|
-
format: 'md',
|
|
223
|
-
markdown: renderMarkdown(result, applied_patches, skipped_patches),
|
|
224
|
-
write_root: writeRoot,
|
|
225
|
-
...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
|
|
226
|
-
...result,
|
|
227
|
-
...(applied_patches ? { applied_patches } : {}),
|
|
228
|
-
...(skipped_patches ? { skipped_patches } : {}),
|
|
229
|
-
})
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return ok({
|
|
233
|
-
status: 'ok',
|
|
234
|
-
command: 'features-audit',
|
|
235
|
-
write_root: writeRoot,
|
|
236
|
-
...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
|
|
237
|
-
...result,
|
|
238
|
-
...(applied_patches ? { applied_patches } : {}),
|
|
239
|
-
...(skipped_patches ? { skipped_patches } : {}),
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function resolveDiffFiles(root, flags) {
|
|
244
|
-
const explicit = flags.get('diff-files')
|
|
245
|
-
if (explicit && typeof explicit === 'string') {
|
|
246
|
-
return explicit
|
|
247
|
-
.split(',')
|
|
248
|
-
.map((s) => s.trim())
|
|
249
|
-
.filter(Boolean)
|
|
250
|
-
}
|
|
251
|
-
const sinceRaw = flags.get('since')
|
|
252
|
-
const since =
|
|
253
|
-
typeof sinceRaw === 'string' && /^[A-Za-z0-9/_.-]+$/.test(sinceRaw) && !sinceRaw.startsWith('-')
|
|
254
|
-
? sinceRaw
|
|
255
|
-
: null
|
|
256
|
-
try {
|
|
257
|
-
const args = since ? ['diff', '--name-only', `${since}..HEAD`] : ['diff', 'HEAD', '--name-only']
|
|
258
|
-
const out = execFileSync('git', args, {
|
|
259
|
-
cwd: root,
|
|
260
|
-
encoding: 'utf-8',
|
|
261
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
262
|
-
})
|
|
263
|
-
return out
|
|
264
|
-
.split('\n')
|
|
265
|
-
.map((s) => s.trim())
|
|
266
|
-
.filter(Boolean)
|
|
267
|
-
} catch {
|
|
268
|
-
// Not a git repo or HEAD doesn't exist — clean slate.
|
|
269
|
-
return []
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function renderMarkdown(result, applied, skipped) {
|
|
274
|
-
const lines = ['### Features audit', '']
|
|
275
|
-
if (result.new_components.length === 0) {
|
|
276
|
-
lines.push('_No new feature-qualifying files in diff._')
|
|
277
|
-
} else {
|
|
278
|
-
lines.push('**New candidates:**')
|
|
279
|
-
for (const c of result.new_components) {
|
|
280
|
-
lines.push(`- \`${c.file}\` — kind: \`${c.kind}\`, area: \`${c.area}\``)
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
if (result.unregistered.length > 0) {
|
|
284
|
-
lines.push('', '**Unregistered:**')
|
|
285
|
-
for (const u of result.unregistered) {
|
|
286
|
-
lines.push(`- \`${u.file}\` (area: \`${u.area}\`)`)
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (applied && applied.length > 0) {
|
|
290
|
-
lines.push('', '**Applied patches:**')
|
|
291
|
-
for (const p of applied) {
|
|
292
|
-
lines.push(`- ${p.target} — ${p.applied ? p.reason : `FAILED: ${p.reason}`}`)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
if (skipped && skipped.length > 0) {
|
|
296
|
-
lines.push('', '**Skipped patches (policy opt-out):**')
|
|
297
|
-
for (const s of skipped) {
|
|
298
|
-
lines.push(`- ${s.target} — policy: \`${s.policy ?? 'unmatched'}\``)
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
return lines.join('\n')
|
|
302
|
-
}
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @internal
|
|
3
|
-
* registry-audit.mjs — features-registry audit + stub emitter (C33).
|
|
4
|
-
*
|
|
5
|
-
* Shared module used by:
|
|
6
|
-
* - `apt-execute` post-step (`apt-tools features-audit --apply-stubs`) —
|
|
7
|
-
* auto-registers new UI/core/hook/IPC files the subtask added.
|
|
8
|
-
* - `apt-reviewer` Pass 6 (`apt-tools features-audit` without `--apply-stubs`)
|
|
9
|
-
* — reports anything the executor missed.
|
|
10
|
-
*
|
|
11
|
-
* Feature kinds (spec §D-4):
|
|
12
|
-
* - `ui-component` — packages/ui/src/sections/** /*.tsx
|
|
13
|
-
* - `core-service` — packages/core/src/{services,pipeline}/**\/*.ts
|
|
14
|
-
* - `hook` — packages/ui/src/hooks/use-*.ts
|
|
15
|
-
* - `ipc-channel` — apps/desktop/src/main/ipc-handlers/*.ts
|
|
16
|
-
*
|
|
17
|
-
* Non-matching paths → null, not included in the audit.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
21
|
-
import { basename, join, resolve } from 'node:path'
|
|
22
|
-
import { atomicWriteJson } from '../util/io.mjs'
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Map a file path to a feature kind, or null if it doesn't qualify.
|
|
26
|
-
*/
|
|
27
|
-
export function detectFeatureKind(filepath) {
|
|
28
|
-
if (!filepath || typeof filepath !== 'string') return null
|
|
29
|
-
const norm = filepath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
30
|
-
// Exclude test files up-front so they never match a feature kind.
|
|
31
|
-
if (/\.(test|spec)\.(ts|tsx)$/.test(norm)) return null
|
|
32
|
-
if (/\/__tests__\//.test(norm)) return null
|
|
33
|
-
// ui-component: packages/ui/src/sections/**/*.tsx
|
|
34
|
-
if (/^packages\/ui\/src\/sections\/.+\.tsx$/.test(norm)) return 'ui-component'
|
|
35
|
-
// core-service: packages/core/src/{services,pipeline}/**/*.ts
|
|
36
|
-
if (/^packages\/core\/src\/(services|pipeline)\/.+\.ts$/.test(norm)) return 'core-service'
|
|
37
|
-
// hook: packages/ui/src/hooks/use-*.ts (also use-*.tsx allowed for hook factories)
|
|
38
|
-
if (/^packages\/ui\/src\/hooks\/use-[A-Za-z0-9-]+\.tsx?$/.test(norm)) return 'hook'
|
|
39
|
-
// ipc-channel: apps/desktop/src/main/ipc-handlers/*.ts
|
|
40
|
-
if (/^apps\/desktop\/src\/main\/ipc-handlers\/[^/]+\.ts$/.test(norm)) return 'ipc-channel'
|
|
41
|
-
return null
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Infer the features-registry "area" that should own a given path. The area
|
|
46
|
-
* is the leaf folder under `sections/`, or a kind-specific pseudo-area when
|
|
47
|
-
* the file isn't under sections/.
|
|
48
|
-
*/
|
|
49
|
-
export function inferArea(filepath) {
|
|
50
|
-
const norm = filepath.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
51
|
-
const m = /^packages\/ui\/src\/sections\/([^/]+)\//.exec(norm)
|
|
52
|
-
if (m) return m[1]
|
|
53
|
-
if (/^packages\/core\/src\/pipeline\//.test(norm)) return 'pipeline'
|
|
54
|
-
if (/^packages\/core\/src\/services\//.test(norm)) return 'services'
|
|
55
|
-
if (/^packages\/ui\/src\/hooks\//.test(norm)) return 'hooks'
|
|
56
|
-
if (/^apps\/desktop\/src\/main\/ipc-handlers\//.test(norm)) return 'ipc'
|
|
57
|
-
return null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Audit a set of diff files against the features registry.
|
|
62
|
-
*
|
|
63
|
-
* @param {object} opts
|
|
64
|
-
* @param {string} opts.projectDir
|
|
65
|
-
* @param {Array<string>} opts.diffFiles — project-relative file paths
|
|
66
|
-
*
|
|
67
|
-
* @returns {{
|
|
68
|
-
* new_components: Array<{file, kind, area}>,
|
|
69
|
-
* unregistered: Array<{file, kind, area}>,
|
|
70
|
-
* stub_patches: Array<StubPatch>,
|
|
71
|
-
* }}
|
|
72
|
-
*
|
|
73
|
-
* StubPatch shape:
|
|
74
|
-
* { target: string, area: string, add_components: string[], create_area?: boolean, kind: string }
|
|
75
|
-
*/
|
|
76
|
-
export function auditFeatureRegistry({ projectDir, diffFiles }) {
|
|
77
|
-
const root = resolve(projectDir)
|
|
78
|
-
const featuresDir = join(root, '.aperant', 'features')
|
|
79
|
-
const existing = existsSync(featuresDir) ? readFeatureArea(featuresDir) : new Map()
|
|
80
|
-
|
|
81
|
-
const new_components = []
|
|
82
|
-
const unregistered = []
|
|
83
|
-
for (const rawFile of diffFiles || []) {
|
|
84
|
-
const file = String(rawFile).replace(/\\/g, '/').replace(/^\.\//, '')
|
|
85
|
-
const kind = detectFeatureKind(file)
|
|
86
|
-
if (!kind) continue
|
|
87
|
-
const area = inferArea(file)
|
|
88
|
-
if (!area) continue
|
|
89
|
-
const entry = { file, kind, area }
|
|
90
|
-
new_components.push(entry)
|
|
91
|
-
const areaData = existing.get(area)
|
|
92
|
-
const components = Array.isArray(areaData?.components) ? areaData.components : null
|
|
93
|
-
if (!components || !components.includes(file)) {
|
|
94
|
-
unregistered.push(entry)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Group unregistered by area → one stub_patch per area.
|
|
99
|
-
const byArea = new Map()
|
|
100
|
-
for (const u of unregistered) {
|
|
101
|
-
if (!byArea.has(u.area)) byArea.set(u.area, [])
|
|
102
|
-
byArea.get(u.area).push(u)
|
|
103
|
-
}
|
|
104
|
-
const stub_patches = []
|
|
105
|
-
for (const [area, entries] of byArea) {
|
|
106
|
-
const target = `.aperant/features/${area}.json`
|
|
107
|
-
const existingAreaData = existing.get(area)
|
|
108
|
-
stub_patches.push({
|
|
109
|
-
target,
|
|
110
|
-
area,
|
|
111
|
-
add_components: entries.map((e) => e.file),
|
|
112
|
-
create_area: !existingAreaData,
|
|
113
|
-
kind: entries[0].kind, // representative kind for new-area stubs
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return { new_components, unregistered, stub_patches }
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Read each `<area>.json` under `.aperant/features/`. Skips REGISTRY.md and
|
|
122
|
-
* malformed files silently (best-effort — the audit should still run).
|
|
123
|
-
*
|
|
124
|
-
* @returns Map<area, { area, route?, components, p4_sub_features?, last_scanned?, kind? }>
|
|
125
|
-
*/
|
|
126
|
-
function readFeatureArea(featuresDir) {
|
|
127
|
-
const out = new Map()
|
|
128
|
-
let names
|
|
129
|
-
try {
|
|
130
|
-
names = readdirSync(featuresDir)
|
|
131
|
-
} catch {
|
|
132
|
-
return out
|
|
133
|
-
}
|
|
134
|
-
for (const name of names) {
|
|
135
|
-
if (!name.endsWith('.json')) continue
|
|
136
|
-
try {
|
|
137
|
-
const data = JSON.parse(readFileSync(join(featuresDir, name), 'utf-8'))
|
|
138
|
-
const area = data?.area || basename(name, '.json')
|
|
139
|
-
out.set(area, data)
|
|
140
|
-
} catch {
|
|
141
|
-
// malformed area file — skip
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return out
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Apply stub patches from `auditFeatureRegistry`. Creates new area files when
|
|
149
|
-
* `create_area: true` and appends new components to existing area files
|
|
150
|
-
* otherwise. Updates REGISTRY.md's total row so the summary reflects the
|
|
151
|
-
* latest counts.
|
|
152
|
-
*
|
|
153
|
-
* @returns Array<{target, applied: boolean, reason?: string}>
|
|
154
|
-
*/
|
|
155
|
-
export function applyStubPatches(projectDir, stubPatches) {
|
|
156
|
-
const root = resolve(projectDir)
|
|
157
|
-
const featuresDir = join(root, '.aperant', 'features')
|
|
158
|
-
mkdirSync(featuresDir, { recursive: true })
|
|
159
|
-
const applied = []
|
|
160
|
-
|
|
161
|
-
for (const patch of stubPatches) {
|
|
162
|
-
const absTarget = join(root, patch.target)
|
|
163
|
-
if (patch.create_area) {
|
|
164
|
-
const stub = {
|
|
165
|
-
area: patch.area,
|
|
166
|
-
route: null,
|
|
167
|
-
components: patch.add_components,
|
|
168
|
-
kind: patch.kind,
|
|
169
|
-
last_scanned: new Date().toISOString().slice(0, 10),
|
|
170
|
-
p4_sub_features: [],
|
|
171
|
-
auto_generated: true,
|
|
172
|
-
}
|
|
173
|
-
atomicWriteJson(absTarget, stub)
|
|
174
|
-
applied.push({ target: patch.target, applied: true, reason: 'created-new-area' })
|
|
175
|
-
continue
|
|
176
|
-
}
|
|
177
|
-
// Append to existing area.
|
|
178
|
-
try {
|
|
179
|
-
const data = JSON.parse(readFileSync(absTarget, 'utf-8'))
|
|
180
|
-
const existing = Array.isArray(data.components) ? data.components : []
|
|
181
|
-
const seen = new Set(existing)
|
|
182
|
-
let added = 0
|
|
183
|
-
for (const f of patch.add_components) {
|
|
184
|
-
if (!seen.has(f)) {
|
|
185
|
-
existing.push(f)
|
|
186
|
-
seen.add(f)
|
|
187
|
-
added += 1
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
data.components = existing
|
|
191
|
-
data.last_scanned = new Date().toISOString().slice(0, 10)
|
|
192
|
-
atomicWriteJson(absTarget, data)
|
|
193
|
-
applied.push({
|
|
194
|
-
target: patch.target,
|
|
195
|
-
applied: true,
|
|
196
|
-
reason: `appended ${added} component${added === 1 ? '' : 's'}`,
|
|
197
|
-
})
|
|
198
|
-
} catch (err) {
|
|
199
|
-
applied.push({ target: patch.target, applied: false, reason: err.message })
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Touch REGISTRY.md — rewrite the TOTAL row to reflect new counts.
|
|
204
|
-
try {
|
|
205
|
-
refreshRegistryTotals(featuresDir)
|
|
206
|
-
} catch {
|
|
207
|
-
// best-effort — a malformed REGISTRY.md shouldn't fail the whole audit.
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return applied
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Best-effort REGISTRY.md refresher. Scans every `<area>.json`, computes total
|
|
215
|
-
* components count, and rewrites the `| **TOTAL** | ...` row if it exists.
|
|
216
|
-
*
|
|
217
|
-
* Intentionally narrow — we do NOT touch individual area rows (which carry
|
|
218
|
-
* manually curated metrics like "Wired %"). The spec says auto-update should
|
|
219
|
-
* "re-compute counts"; we treat the TOTAL row as the safe proxy.
|
|
220
|
-
*/
|
|
221
|
-
function refreshRegistryTotals(featuresDir) {
|
|
222
|
-
const regPath = join(featuresDir, 'REGISTRY.md')
|
|
223
|
-
if (!existsSync(regPath)) return
|
|
224
|
-
const text = readFileSync(regPath, 'utf-8')
|
|
225
|
-
const totalComponents = tallyAllComponents(featuresDir)
|
|
226
|
-
const lines = text.split(/\r?\n/)
|
|
227
|
-
let rewrote = false
|
|
228
|
-
for (let i = 0; i < lines.length; i++) {
|
|
229
|
-
if (/^\|\s*\*\*TOTAL\*\*\s*\|/.test(lines[i])) {
|
|
230
|
-
// Rewrite as a minimal diagnostic row — preserve the shape but update
|
|
231
|
-
// the leading count columns. We don't re-derive wired %.
|
|
232
|
-
lines[i] = `| **TOTAL** | | | **${totalComponents}** | | | | auto-refreshed |`
|
|
233
|
-
rewrote = true
|
|
234
|
-
break
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
if (rewrote) {
|
|
238
|
-
writeFileSync(regPath, lines.join('\n'))
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function tallyAllComponents(featuresDir) {
|
|
243
|
-
let count = 0
|
|
244
|
-
for (const name of readdirSync(featuresDir)) {
|
|
245
|
-
if (!name.endsWith('.json')) continue
|
|
246
|
-
try {
|
|
247
|
-
const data = JSON.parse(readFileSync(join(featuresDir, name), 'utf-8'))
|
|
248
|
-
count += Array.isArray(data.components) ? data.components.length : 0
|
|
249
|
-
} catch {
|
|
250
|
-
/* ignore */
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return count
|
|
254
|
-
}
|