tenbo-dashboard 0.6.0 → 0.7.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/bin/tenbo-dashboard.mjs +4 -0
- package/package.json +1 -1
- package/scripts/hook.test.ts +144 -0
- package/scripts/hook.ts +402 -0
- package/scripts/validate-cli.ts +9 -1
- package/src/api/lib/validator.duplicateIds.test.ts +89 -0
- package/src/api/lib/validator.preflightViolations.test.ts +67 -0
- package/src/api/lib/validator.ts +74 -6
- package/src/types.ts +12 -0
package/bin/tenbo-dashboard.mjs
CHANGED
|
@@ -40,6 +40,7 @@ const commands = {
|
|
|
40
40
|
'init-check': 'scripts/init-check.ts',
|
|
41
41
|
sync: 'scripts/sync.ts',
|
|
42
42
|
archive: 'scripts/archive.ts',
|
|
43
|
+
hook: 'scripts/hook.ts',
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
// Documented aliases for the bare-launch behavior. Users (and agents) reading
|
|
@@ -82,6 +83,9 @@ Usage:
|
|
|
82
83
|
tenbo-dashboard metrics --all Compute scope metrics
|
|
83
84
|
tenbo-dashboard archive Move old done/dropped items to roadmap-archive.yaml
|
|
84
85
|
[--scope <id>] [--days 30] [--max 20]
|
|
86
|
+
tenbo-dashboard hook install Install opt-in pre-commit validation hook
|
|
87
|
+
[--dry-run] [--force]
|
|
88
|
+
tenbo-dashboard hook uninstall Remove the tenbo pre-commit hook (idempotent)
|
|
85
89
|
tenbo-dashboard --version Print package version
|
|
86
90
|
tenbo-dashboard help Show this help
|
|
87
91
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
planInstall,
|
|
7
|
+
applyInstall,
|
|
8
|
+
planUninstall,
|
|
9
|
+
applyUninstall,
|
|
10
|
+
formatInstallPlan,
|
|
11
|
+
stripHuskyBlock,
|
|
12
|
+
TENBO_HEADER_MARKER,
|
|
13
|
+
TENBO_HUSKY_BEGIN,
|
|
14
|
+
TENBO_HUSKY_END,
|
|
15
|
+
HOOK_BODY,
|
|
16
|
+
} from './hook';
|
|
17
|
+
|
|
18
|
+
let tmp: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tenbo-hook-'));
|
|
22
|
+
fs.mkdirSync(path.join(tmp, '.git', 'hooks'), { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('planInstall', () => {
|
|
30
|
+
it('plans a standalone install when no pre-commit hook exists', () => {
|
|
31
|
+
const plan = planInstall({ repoRoot: tmp });
|
|
32
|
+
expect(plan.mode).toBe('standalone');
|
|
33
|
+
expect(plan.writes).toHaveLength(1);
|
|
34
|
+
expect(plan.moves).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('detects an already-installed tenbo hook (header present)', () => {
|
|
38
|
+
const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
|
|
39
|
+
fs.writeFileSync(hookPath, `#!/usr/bin/env sh\n${TENBO_HEADER_MARKER}\necho hi\n`);
|
|
40
|
+
const plan = planInstall({ repoRoot: tmp });
|
|
41
|
+
expect(plan.mode).toBe('already-installed');
|
|
42
|
+
expect(plan.writes).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('chains a foreign hook by moving it aside to .local', () => {
|
|
46
|
+
const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
|
|
47
|
+
fs.writeFileSync(hookPath, '#!/bin/sh\necho foreign\n');
|
|
48
|
+
const plan = planInstall({ repoRoot: tmp });
|
|
49
|
+
expect(plan.mode).toBe('chained');
|
|
50
|
+
expect(plan.moves[0].to.endsWith('.local')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('--force overwrites a foreign hook in place', () => {
|
|
54
|
+
const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
|
|
55
|
+
fs.writeFileSync(hookPath, '#!/bin/sh\necho foreign\n');
|
|
56
|
+
const plan = planInstall({ repoRoot: tmp, force: true });
|
|
57
|
+
expect(plan.mode).toBe('standalone');
|
|
58
|
+
expect(plan.moves).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('prefers husky integration when .husky/pre-commit exists', () => {
|
|
62
|
+
const huskyPath = path.join(tmp, '.husky', 'pre-commit');
|
|
63
|
+
fs.mkdirSync(path.dirname(huskyPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(huskyPath, '#!/bin/sh\nnpx lint-staged\n');
|
|
65
|
+
const plan = planInstall({ repoRoot: tmp });
|
|
66
|
+
expect(plan.mode).toBe('husky');
|
|
67
|
+
expect(plan.writes[0].path).toBe(huskyPath);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('applyInstall + applyUninstall round-trip', () => {
|
|
72
|
+
it('standalone install creates an executable hook with the tenbo header', () => {
|
|
73
|
+
const plan = planInstall({ repoRoot: tmp });
|
|
74
|
+
applyInstall(plan);
|
|
75
|
+
const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
|
|
76
|
+
const body = fs.readFileSync(hookPath, 'utf8');
|
|
77
|
+
expect(body).toBe(HOOK_BODY);
|
|
78
|
+
expect(body).toContain(TENBO_HEADER_MARKER);
|
|
79
|
+
expect((fs.statSync(hookPath).mode & 0o111) !== 0).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('chained install preserves the foreign hook at .local and restores on uninstall', () => {
|
|
83
|
+
const hookPath = path.join(tmp, '.git', 'hooks', 'pre-commit');
|
|
84
|
+
const original = '#!/bin/sh\necho foreign-hook\n';
|
|
85
|
+
fs.writeFileSync(hookPath, original);
|
|
86
|
+
fs.chmodSync(hookPath, 0o755);
|
|
87
|
+
|
|
88
|
+
applyInstall(planInstall({ repoRoot: tmp }));
|
|
89
|
+
expect(fs.readFileSync(hookPath + '.local', 'utf8')).toBe(original);
|
|
90
|
+
expect(fs.readFileSync(hookPath, 'utf8')).toContain(TENBO_HEADER_MARKER);
|
|
91
|
+
|
|
92
|
+
applyUninstall(planUninstall(tmp));
|
|
93
|
+
expect(fs.existsSync(hookPath + '.local')).toBe(false);
|
|
94
|
+
expect(fs.readFileSync(hookPath, 'utf8')).toBe(original);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('husky install appends a delimited block; uninstall removes only that block', () => {
|
|
98
|
+
const huskyPath = path.join(tmp, '.husky', 'pre-commit');
|
|
99
|
+
fs.mkdirSync(path.dirname(huskyPath), { recursive: true });
|
|
100
|
+
const original = '#!/bin/sh\nnpx lint-staged\n';
|
|
101
|
+
fs.writeFileSync(huskyPath, original);
|
|
102
|
+
|
|
103
|
+
applyInstall(planInstall({ repoRoot: tmp }));
|
|
104
|
+
const after = fs.readFileSync(huskyPath, 'utf8');
|
|
105
|
+
expect(after).toContain(TENBO_HUSKY_BEGIN);
|
|
106
|
+
expect(after).toContain(TENBO_HUSKY_END);
|
|
107
|
+
expect(after).toContain('npx lint-staged');
|
|
108
|
+
|
|
109
|
+
applyUninstall(planUninstall(tmp));
|
|
110
|
+
const restored = fs.readFileSync(huskyPath, 'utf8');
|
|
111
|
+
expect(restored).not.toContain(TENBO_HUSKY_BEGIN);
|
|
112
|
+
expect(restored).toContain('npx lint-staged');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('planUninstall', () => {
|
|
117
|
+
it('reports not-installed when no hook exists', () => {
|
|
118
|
+
expect(planUninstall(tmp).mode).toBe('not-installed');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('leaves foreign hooks alone (not-installed)', () => {
|
|
122
|
+
fs.writeFileSync(path.join(tmp, '.git', 'hooks', 'pre-commit'), '#!/bin/sh\necho foreign\n');
|
|
123
|
+
expect(planUninstall(tmp).mode).toBe('not-installed');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('stripHuskyBlock', () => {
|
|
128
|
+
it('removes only the tenbo block, preserving surrounding lines', () => {
|
|
129
|
+
const input = `line one\n\n${TENBO_HUSKY_BEGIN}\necho tenbo\n${TENBO_HUSKY_END}\nline two\n`;
|
|
130
|
+
const out = stripHuskyBlock(input);
|
|
131
|
+
expect(out).toContain('line one');
|
|
132
|
+
expect(out).toContain('line two');
|
|
133
|
+
expect(out).not.toContain('echo tenbo');
|
|
134
|
+
expect(out).not.toContain(TENBO_HUSKY_BEGIN);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('formatInstallPlan', () => {
|
|
139
|
+
it('renders a standalone plan with mode + writes', () => {
|
|
140
|
+
const out = formatInstallPlan(planInstall({ repoRoot: tmp }));
|
|
141
|
+
expect(out).toContain('Install mode: standalone');
|
|
142
|
+
expect(out).toContain('write:');
|
|
143
|
+
});
|
|
144
|
+
});
|
package/scripts/hook.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook.ts — install / uninstall the opt-in tenbo pre-commit hook.
|
|
3
|
+
*
|
|
4
|
+
* Runs the deterministic validator (`tenbo-dashboard validate --strict`) at
|
|
5
|
+
* commit time so duplicate-IDs, broken refs, and schema violations surface
|
|
6
|
+
* BEFORE bad shapes enter history. Tier 1 only — no LLM, sub-500ms.
|
|
7
|
+
*
|
|
8
|
+
* Subcommands (from `tenbo-dashboard hook ...`):
|
|
9
|
+
* install [--dry-run] [--force]
|
|
10
|
+
* uninstall
|
|
11
|
+
* status (introspect — useful for tests + users)
|
|
12
|
+
*
|
|
13
|
+
* Install strategy (in order of preference):
|
|
14
|
+
* 1. If `.husky/pre-commit` exists → append a clearly-delimited tenbo block
|
|
15
|
+
* to the husky file. Less surprising for husky users than touching
|
|
16
|
+
* `.git/hooks/`.
|
|
17
|
+
* 2. Else if `.git/hooks/pre-commit` is absent → create it standalone with
|
|
18
|
+
* the tenbo header.
|
|
19
|
+
* 3. Else if existing `.git/hooks/pre-commit` already carries the tenbo
|
|
20
|
+
* header → no-op (already installed).
|
|
21
|
+
* 4. Else (foreign hook present) → chained-hook pattern: move existing to
|
|
22
|
+
* `pre-commit.local`, write a new `pre-commit` that runs the local one
|
|
23
|
+
* first (preserving exit code) then the tenbo check.
|
|
24
|
+
*
|
|
25
|
+
* Uninstall is surgical and idempotent — only undoes what install added.
|
|
26
|
+
*
|
|
27
|
+
* Written in vanilla TS (no external deps) and exported as pure functions
|
|
28
|
+
* for unit testing. The `if (isMain())` block runs the CLI side-effects.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { findRepoRoot } from '../src/api/lib/repoRoot';
|
|
33
|
+
|
|
34
|
+
export const TENBO_HEADER_MARKER = '# tenbo-managed pre-commit hook (sk-032)';
|
|
35
|
+
export const TENBO_HUSKY_BEGIN = '### tenbo-managed (sk-032) BEGIN ###';
|
|
36
|
+
export const TENBO_HUSKY_END = '### tenbo-managed (sk-032) END ###';
|
|
37
|
+
|
|
38
|
+
/** The body of the pre-commit script the hook runs (POSIX sh, no bash-isms). */
|
|
39
|
+
export const HOOK_BODY = `#!/usr/bin/env sh
|
|
40
|
+
${TENBO_HEADER_MARKER}
|
|
41
|
+
# Runs the deterministic validator on .tenbo/ before allowing commit.
|
|
42
|
+
# Errors block commit; warnings print but don't block.
|
|
43
|
+
# Bypass with \`git commit --no-verify\`.
|
|
44
|
+
|
|
45
|
+
# Skip if no .tenbo/ in repo (graceful no-op for non-tenbo repos)
|
|
46
|
+
if [ ! -d .tenbo ]; then exit 0; fi
|
|
47
|
+
|
|
48
|
+
# Run validate with strict mode
|
|
49
|
+
npx --no-install tenbo-dashboard validate --strict
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
/** Husky-style block, appended to an existing .husky/pre-commit file. */
|
|
53
|
+
export const HUSKY_BLOCK = `\n${TENBO_HUSKY_BEGIN}
|
|
54
|
+
if [ -d .tenbo ]; then
|
|
55
|
+
npx --no-install tenbo-dashboard validate --strict || exit $?
|
|
56
|
+
fi
|
|
57
|
+
${TENBO_HUSKY_END}\n`;
|
|
58
|
+
|
|
59
|
+
/** Chained-hook pre-commit body when a foreign hook was present. */
|
|
60
|
+
export function chainedHookBody(localHookRelPath: string): string {
|
|
61
|
+
return `#!/usr/bin/env sh
|
|
62
|
+
${TENBO_HEADER_MARKER}
|
|
63
|
+
# Chained install — runs the previously-installed hook first, then tenbo's
|
|
64
|
+
# validator. Uninstalling restores the local hook to its original location.
|
|
65
|
+
|
|
66
|
+
LOCAL_HOOK="$(dirname "$0")/${path.basename(localHookRelPath)}"
|
|
67
|
+
if [ -x "$LOCAL_HOOK" ]; then
|
|
68
|
+
"$LOCAL_HOOK" "$@" || exit $?
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [ -d .tenbo ]; then
|
|
72
|
+
npx --no-install tenbo-dashboard validate --strict
|
|
73
|
+
fi
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type InstallMode = 'standalone' | 'chained' | 'husky' | 'already-installed';
|
|
78
|
+
|
|
79
|
+
export interface InstallPlan {
|
|
80
|
+
mode: InstallMode;
|
|
81
|
+
/** Absolute paths the install would write/move. */
|
|
82
|
+
writes: { path: string; reason: string; chmod?: boolean }[];
|
|
83
|
+
moves: { from: string; to: string }[];
|
|
84
|
+
notes: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface InstallContext {
|
|
88
|
+
repoRoot: string;
|
|
89
|
+
/** Whether to overwrite a foreign hook in place (skips chaining). */
|
|
90
|
+
force?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isTenboManaged(content: string): boolean {
|
|
94
|
+
return content.includes(TENBO_HEADER_MARKER) || content.includes(TENBO_HUSKY_BEGIN);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Compute the install plan WITHOUT touching the filesystem. */
|
|
98
|
+
export function planInstall(ctx: InstallContext): InstallPlan {
|
|
99
|
+
const huskyPath = path.join(ctx.repoRoot, '.husky', 'pre-commit');
|
|
100
|
+
const hookPath = path.join(ctx.repoRoot, '.git', 'hooks', 'pre-commit');
|
|
101
|
+
|
|
102
|
+
// Husky integration: prefer if .husky/pre-commit exists.
|
|
103
|
+
if (fs.existsSync(huskyPath)) {
|
|
104
|
+
const content = fs.readFileSync(huskyPath, 'utf8');
|
|
105
|
+
if (isTenboManaged(content)) {
|
|
106
|
+
return {
|
|
107
|
+
mode: 'already-installed',
|
|
108
|
+
writes: [],
|
|
109
|
+
moves: [],
|
|
110
|
+
notes: [`tenbo block already present in ${huskyPath}`],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
mode: 'husky',
|
|
115
|
+
writes: [{ path: huskyPath, reason: 'append tenbo block to existing husky pre-commit', chmod: true }],
|
|
116
|
+
moves: [],
|
|
117
|
+
notes: [
|
|
118
|
+
`Detected husky at ${huskyPath} — appending tenbo block (delimited by ${TENBO_HUSKY_BEGIN} / ${TENBO_HUSKY_END}).`,
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// No husky. Look at .git/hooks/pre-commit.
|
|
124
|
+
if (!fs.existsSync(hookPath)) {
|
|
125
|
+
return {
|
|
126
|
+
mode: 'standalone',
|
|
127
|
+
writes: [{ path: hookPath, reason: 'create new pre-commit hook (no existing hook found)', chmod: true }],
|
|
128
|
+
moves: [],
|
|
129
|
+
notes: ['No existing pre-commit hook found — installing standalone.'],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
134
|
+
if (isTenboManaged(existing)) {
|
|
135
|
+
return {
|
|
136
|
+
mode: 'already-installed',
|
|
137
|
+
writes: [],
|
|
138
|
+
moves: [],
|
|
139
|
+
notes: [`Tenbo header already present in ${hookPath}`],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (ctx.force) {
|
|
144
|
+
return {
|
|
145
|
+
mode: 'standalone',
|
|
146
|
+
writes: [{ path: hookPath, reason: 'overwrite foreign hook (--force)', chmod: true }],
|
|
147
|
+
moves: [],
|
|
148
|
+
notes: [`--force: overwriting existing non-tenbo hook at ${hookPath}.`],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Chained install — preserve existing hook by moving it aside.
|
|
153
|
+
const localPath = hookPath + '.local';
|
|
154
|
+
return {
|
|
155
|
+
mode: 'chained',
|
|
156
|
+
writes: [{ path: hookPath, reason: 'write chained hook that runs local then tenbo', chmod: true }],
|
|
157
|
+
moves: [{ from: hookPath, to: localPath }],
|
|
158
|
+
notes: [
|
|
159
|
+
`Existing non-tenbo hook detected at ${hookPath}.`,
|
|
160
|
+
`It will be preserved at ${localPath} and chained: local hook runs first, then tenbo validator.`,
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Apply an install plan. Returns the same plan for caller reporting. */
|
|
166
|
+
export function applyInstall(plan: InstallPlan): InstallPlan {
|
|
167
|
+
// Moves first (so chained mode can free the original path before write).
|
|
168
|
+
for (const m of plan.moves) {
|
|
169
|
+
fs.renameSync(m.from, m.to);
|
|
170
|
+
}
|
|
171
|
+
for (const w of plan.writes) {
|
|
172
|
+
fs.mkdirSync(path.dirname(w.path), { recursive: true });
|
|
173
|
+
let body: string;
|
|
174
|
+
if (plan.mode === 'husky') {
|
|
175
|
+
const existing = fs.existsSync(w.path) ? fs.readFileSync(w.path, 'utf8') : '';
|
|
176
|
+
body = existing.endsWith('\n') ? existing + HUSKY_BLOCK : existing + '\n' + HUSKY_BLOCK;
|
|
177
|
+
} else if (plan.mode === 'chained') {
|
|
178
|
+
const moved = plan.moves.find((m) => m.to.endsWith('.local'));
|
|
179
|
+
body = chainedHookBody(moved ? moved.to : 'pre-commit.local');
|
|
180
|
+
} else {
|
|
181
|
+
body = HOOK_BODY;
|
|
182
|
+
}
|
|
183
|
+
fs.writeFileSync(w.path, body);
|
|
184
|
+
if (w.chmod) fs.chmodSync(w.path, 0o755);
|
|
185
|
+
}
|
|
186
|
+
return plan;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export type UninstallMode = 'standalone' | 'chained' | 'husky' | 'not-installed';
|
|
190
|
+
|
|
191
|
+
export interface UninstallPlan {
|
|
192
|
+
mode: UninstallMode;
|
|
193
|
+
removes: string[];
|
|
194
|
+
/** Move (restore) operations: e.g. `pre-commit.local` → `pre-commit`. */
|
|
195
|
+
restores: { from: string; to: string }[];
|
|
196
|
+
/** In-place edits (husky block removal). */
|
|
197
|
+
edits: { path: string; before: string; after: string }[];
|
|
198
|
+
notes: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function planUninstall(repoRoot: string): UninstallPlan {
|
|
202
|
+
const huskyPath = path.join(repoRoot, '.husky', 'pre-commit');
|
|
203
|
+
const hookPath = path.join(repoRoot, '.git', 'hooks', 'pre-commit');
|
|
204
|
+
const localPath = hookPath + '.local';
|
|
205
|
+
|
|
206
|
+
// Husky path takes priority if the husky file carries the tenbo block.
|
|
207
|
+
if (fs.existsSync(huskyPath)) {
|
|
208
|
+
const content = fs.readFileSync(huskyPath, 'utf8');
|
|
209
|
+
if (content.includes(TENBO_HUSKY_BEGIN)) {
|
|
210
|
+
const after = stripHuskyBlock(content);
|
|
211
|
+
return {
|
|
212
|
+
mode: 'husky',
|
|
213
|
+
removes: [],
|
|
214
|
+
restores: [],
|
|
215
|
+
edits: [{ path: huskyPath, before: content, after }],
|
|
216
|
+
notes: [`Removing tenbo block from ${huskyPath}.`],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!fs.existsSync(hookPath)) {
|
|
222
|
+
return { mode: 'not-installed', removes: [], restores: [], edits: [], notes: ['No tenbo-managed hook found.'] };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const content = fs.readFileSync(hookPath, 'utf8');
|
|
226
|
+
if (!content.includes(TENBO_HEADER_MARKER)) {
|
|
227
|
+
return {
|
|
228
|
+
mode: 'not-installed',
|
|
229
|
+
removes: [],
|
|
230
|
+
restores: [],
|
|
231
|
+
edits: [],
|
|
232
|
+
notes: [`Hook at ${hookPath} is not tenbo-managed — leaving it alone.`],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Tenbo-managed. Chained if a sibling .local exists.
|
|
237
|
+
if (fs.existsSync(localPath)) {
|
|
238
|
+
return {
|
|
239
|
+
mode: 'chained',
|
|
240
|
+
removes: [],
|
|
241
|
+
restores: [{ from: localPath, to: hookPath }],
|
|
242
|
+
edits: [],
|
|
243
|
+
notes: [`Restoring previous hook from ${localPath} → ${hookPath}.`],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
mode: 'standalone',
|
|
249
|
+
removes: [hookPath],
|
|
250
|
+
restores: [],
|
|
251
|
+
edits: [],
|
|
252
|
+
notes: [`Removing tenbo-managed standalone hook at ${hookPath}.`],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function applyUninstall(plan: UninstallPlan): UninstallPlan {
|
|
257
|
+
for (const r of plan.removes) {
|
|
258
|
+
if (fs.existsSync(r)) fs.unlinkSync(r);
|
|
259
|
+
}
|
|
260
|
+
for (const r of plan.restores) {
|
|
261
|
+
if (fs.existsSync(r.to)) fs.unlinkSync(r.to);
|
|
262
|
+
fs.renameSync(r.from, r.to);
|
|
263
|
+
fs.chmodSync(r.to, 0o755);
|
|
264
|
+
}
|
|
265
|
+
for (const e of plan.edits) {
|
|
266
|
+
fs.writeFileSync(e.path, e.after);
|
|
267
|
+
}
|
|
268
|
+
return plan;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Strip the husky tenbo block (between BEGIN/END markers, inclusive). Pure. */
|
|
272
|
+
export function stripHuskyBlock(content: string): string {
|
|
273
|
+
const lines = content.split('\n');
|
|
274
|
+
const out: string[] = [];
|
|
275
|
+
let inside = false;
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
if (line.includes(TENBO_HUSKY_BEGIN)) {
|
|
278
|
+
inside = true;
|
|
279
|
+
// Drop a single leading blank line we may have added for spacing.
|
|
280
|
+
if (out.length && out[out.length - 1] === '') out.pop();
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (line.includes(TENBO_HUSKY_END)) {
|
|
284
|
+
inside = false;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!inside) out.push(line);
|
|
288
|
+
}
|
|
289
|
+
return out.join('\n');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Pretty-print a plan for --dry-run. */
|
|
293
|
+
export function formatInstallPlan(plan: InstallPlan): string {
|
|
294
|
+
const lines: string[] = [];
|
|
295
|
+
lines.push(`Install mode: ${plan.mode}`);
|
|
296
|
+
for (const n of plan.notes) lines.push(` - ${n}`);
|
|
297
|
+
for (const m of plan.moves) lines.push(` move: ${m.from} → ${m.to}`);
|
|
298
|
+
for (const w of plan.writes) lines.push(` write: ${w.path} (${w.reason})`);
|
|
299
|
+
if (plan.mode === 'already-installed') lines.push(' (no changes — already installed)');
|
|
300
|
+
return lines.join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function formatUninstallPlan(plan: UninstallPlan): string {
|
|
304
|
+
const lines: string[] = [];
|
|
305
|
+
lines.push(`Uninstall mode: ${plan.mode}`);
|
|
306
|
+
for (const n of plan.notes) lines.push(` - ${n}`);
|
|
307
|
+
for (const r of plan.removes) lines.push(` remove: ${r}`);
|
|
308
|
+
for (const r of plan.restores) lines.push(` restore: ${r.from} → ${r.to}`);
|
|
309
|
+
for (const e of plan.edits) lines.push(` edit: ${e.path}`);
|
|
310
|
+
return lines.join('\n');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isMain(): boolean {
|
|
314
|
+
try {
|
|
315
|
+
const invoked = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
316
|
+
const here = path.resolve(new URL(import.meta.url).pathname);
|
|
317
|
+
return invoked === here;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function printHelp(): void {
|
|
324
|
+
process.stdout.write(`tenbo-dashboard hook — opt-in pre-commit hook (sk-032)
|
|
325
|
+
|
|
326
|
+
Usage:
|
|
327
|
+
tenbo-dashboard hook install [--dry-run] [--force]
|
|
328
|
+
tenbo-dashboard hook uninstall
|
|
329
|
+
tenbo-dashboard hook status
|
|
330
|
+
|
|
331
|
+
Install runs the deterministic validator on .tenbo/ at commit time.
|
|
332
|
+
Errors block commit; warnings print but don't block.
|
|
333
|
+
Bypass per-commit with \`git commit --no-verify\`.
|
|
334
|
+
|
|
335
|
+
Flags:
|
|
336
|
+
--dry-run Print exactly what would change without writing.
|
|
337
|
+
--force Overwrite an existing non-tenbo hook (use with care).
|
|
338
|
+
|
|
339
|
+
Notes:
|
|
340
|
+
- Existing .git/hooks/pre-commit (foreign) is preserved via chained-hook pattern.
|
|
341
|
+
- .husky/pre-commit users get a delimited block appended (no .git/hooks/ touch).
|
|
342
|
+
- Re-running install after install is a no-op.
|
|
343
|
+
`);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (isMain()) {
|
|
347
|
+
const args = process.argv.slice(2);
|
|
348
|
+
const action = args[0];
|
|
349
|
+
const cwd = process.cwd();
|
|
350
|
+
const repoRoot = findRepoRoot(cwd);
|
|
351
|
+
|
|
352
|
+
if (!action || action === 'help' || action === '--help') {
|
|
353
|
+
printHelp();
|
|
354
|
+
process.exit(0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!repoRoot) {
|
|
358
|
+
process.stderr.write('hook: not inside a git repository (no .git/ found in any parent).\n');
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (action === 'install') {
|
|
363
|
+
const dryRun = args.includes('--dry-run');
|
|
364
|
+
const force = args.includes('--force');
|
|
365
|
+
const plan = planInstall({ repoRoot, force });
|
|
366
|
+
if (dryRun) {
|
|
367
|
+
process.stdout.write(formatInstallPlan(plan) + '\n');
|
|
368
|
+
process.stdout.write('(dry-run — no changes written)\n');
|
|
369
|
+
process.exit(0);
|
|
370
|
+
}
|
|
371
|
+
if (plan.mode === 'already-installed') {
|
|
372
|
+
process.stdout.write('tenbo pre-commit hook already installed. No changes.\n');
|
|
373
|
+
process.exit(0);
|
|
374
|
+
}
|
|
375
|
+
applyInstall(plan);
|
|
376
|
+
process.stdout.write(formatInstallPlan(plan) + '\n');
|
|
377
|
+
process.stdout.write('Installed. Bypass with `git commit --no-verify`. Uninstall with `npx tenbo-dashboard hook uninstall`.\n');
|
|
378
|
+
process.exit(0);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (action === 'uninstall') {
|
|
382
|
+
const plan = planUninstall(repoRoot);
|
|
383
|
+
if (plan.mode === 'not-installed') {
|
|
384
|
+
process.stdout.write('No tenbo-managed pre-commit hook found. Nothing to do.\n');
|
|
385
|
+
process.exit(0);
|
|
386
|
+
}
|
|
387
|
+
applyUninstall(plan);
|
|
388
|
+
process.stdout.write(formatUninstallPlan(plan) + '\n');
|
|
389
|
+
process.stdout.write('Uninstalled.\n');
|
|
390
|
+
process.exit(0);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (action === 'status') {
|
|
394
|
+
const plan = planInstall({ repoRoot });
|
|
395
|
+
process.stdout.write(`hook status: ${plan.mode === 'already-installed' ? 'installed' : 'not installed'}\n`);
|
|
396
|
+
for (const n of plan.notes) process.stdout.write(` - ${n}\n`);
|
|
397
|
+
process.exit(0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
process.stderr.write(`hook: unknown action '${action}'. Run \`tenbo-dashboard hook help\`.\n`);
|
|
401
|
+
process.exit(2);
|
|
402
|
+
}
|
package/scripts/validate-cli.ts
CHANGED
|
@@ -50,6 +50,13 @@ export function computeDiff(prev: Snapshot | null, current: Snapshot): DiffResul
|
|
|
50
50
|
export interface RenderOptions {
|
|
51
51
|
verbose?: boolean;
|
|
52
52
|
json?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Strict mode (sk-032): reserved for future stricter behavior. Today the
|
|
55
|
+
* default already exits non-zero on errors and 0 on warnings, so --strict
|
|
56
|
+
* is a no-op semantically but a stable contract for the pre-commit hook.
|
|
57
|
+
* If we ever introduce a warning-blocks-commit mode it lands here.
|
|
58
|
+
*/
|
|
59
|
+
strict?: boolean;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface RenderResult {
|
|
@@ -152,6 +159,7 @@ if (isMain()) {
|
|
|
152
159
|
const args = process.argv.slice(2);
|
|
153
160
|
const verbose = args.includes('--verbose');
|
|
154
161
|
const json = args.includes('--json');
|
|
162
|
+
const strict = args.includes('--strict');
|
|
155
163
|
|
|
156
164
|
const cwd = process.cwd();
|
|
157
165
|
const repoRoot = findRepoRoot(cwd) ?? path.resolve(cwd, '..', '..');
|
|
@@ -169,7 +177,7 @@ if (isMain()) {
|
|
|
169
177
|
fs.writeFileSync(statusPath, JSON.stringify(snapshot, null, 2) + '\n');
|
|
170
178
|
|
|
171
179
|
const diff = computeDiff(prev, snapshot);
|
|
172
|
-
const rendered = renderOutput(snapshot, diff, { verbose, json });
|
|
180
|
+
const rendered = renderOutput(snapshot, diff, { verbose, json, strict });
|
|
173
181
|
if (rendered.stdout) process.stdout.write(rendered.stdout);
|
|
174
182
|
if (rendered.stderr) process.stderr.write(rendered.stderr);
|
|
175
183
|
if (!json && !verbose) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validate } from './validator';
|
|
3
|
+
import type { TenboState, Item } from '../../types';
|
|
4
|
+
|
|
5
|
+
const baseItem = (over: Partial<Item> = {}): Item => ({
|
|
6
|
+
id: 'ed-001',
|
|
7
|
+
title: 't',
|
|
8
|
+
layer: 'l',
|
|
9
|
+
status: 'later',
|
|
10
|
+
description: 'a roadmap item used for duplicate-id tests now',
|
|
11
|
+
...over,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function stateWithScopeItems(items: Item[], archived: Item[] = []): TenboState {
|
|
15
|
+
return {
|
|
16
|
+
scopes: [{
|
|
17
|
+
id: 'e',
|
|
18
|
+
path: '.',
|
|
19
|
+
description: 'editor scope description here for tests',
|
|
20
|
+
layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
|
|
21
|
+
items,
|
|
22
|
+
archivedItems: archived,
|
|
23
|
+
}],
|
|
24
|
+
crossCutting: [],
|
|
25
|
+
narratives: { 'e/l': '# L' },
|
|
26
|
+
} as any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dupErrors = (state: TenboState) =>
|
|
30
|
+
validate(state).errors.filter((e) => /^duplicate item id/.test(e.message)).map((e) => e.message);
|
|
31
|
+
|
|
32
|
+
describe('validator — duplicate item ids', () => {
|
|
33
|
+
it('emits no duplicate-id error when every id appears once', () => {
|
|
34
|
+
const state = stateWithScopeItems([
|
|
35
|
+
baseItem({ id: 'ed-001' }),
|
|
36
|
+
baseItem({ id: 'ed-002' }),
|
|
37
|
+
baseItem({ id: 'ed-003' }),
|
|
38
|
+
]);
|
|
39
|
+
expect(dupErrors(state)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('errors when two active items share an id', () => {
|
|
43
|
+
const state = stateWithScopeItems([
|
|
44
|
+
baseItem({ id: 'ed-001' }),
|
|
45
|
+
baseItem({ id: 'ed-001', title: 'dup' }),
|
|
46
|
+
]);
|
|
47
|
+
const msgs = dupErrors(state);
|
|
48
|
+
expect(msgs.length).toBe(1);
|
|
49
|
+
expect(msgs[0]).toMatch(/duplicate item id "ed-001"/);
|
|
50
|
+
expect(msgs[0]).toMatch(/active/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('errors when an id appears once in active and once in archive', () => {
|
|
54
|
+
const state = stateWithScopeItems(
|
|
55
|
+
[baseItem({ id: 'ed-001' })],
|
|
56
|
+
[baseItem({ id: 'ed-001', status: 'done' })],
|
|
57
|
+
);
|
|
58
|
+
const msgs = dupErrors(state);
|
|
59
|
+
expect(msgs.length).toBe(1);
|
|
60
|
+
expect(msgs[0]).toMatch(/active/);
|
|
61
|
+
expect(msgs[0]).toMatch(/archive/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('errors when an id appears in cross-cutting and a scope', () => {
|
|
65
|
+
const state: TenboState = {
|
|
66
|
+
...stateWithScopeItems([baseItem({ id: 'x-001' })]),
|
|
67
|
+
crossCuttingRoadmap: [
|
|
68
|
+
{ id: 'x-001', title: 'cross', status: 'now', description: 'a cross-cutting item', layers: [] } as any,
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
const msgs = dupErrors(state);
|
|
72
|
+
expect(msgs.length).toBe(1);
|
|
73
|
+
expect(msgs[0]).toMatch(/cross-cutting/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('emits one error per duplicated id, not one per duplicate occurrence', () => {
|
|
77
|
+
const state = stateWithScopeItems([
|
|
78
|
+
baseItem({ id: 'ed-001' }),
|
|
79
|
+
baseItem({ id: 'ed-001' }),
|
|
80
|
+
baseItem({ id: 'ed-002' }),
|
|
81
|
+
baseItem({ id: 'ed-002' }),
|
|
82
|
+
baseItem({ id: 'ed-003' }),
|
|
83
|
+
]);
|
|
84
|
+
const msgs = dupErrors(state);
|
|
85
|
+
expect(msgs.length).toBe(2);
|
|
86
|
+
expect(msgs.some((m) => m.includes('ed-001'))).toBe(true);
|
|
87
|
+
expect(msgs.some((m) => m.includes('ed-002'))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validate } from './validator';
|
|
3
|
+
import type { TenboState, Item } from '../../types';
|
|
4
|
+
|
|
5
|
+
function stateWithItems(items: Item[]): TenboState {
|
|
6
|
+
return {
|
|
7
|
+
scopes: [{
|
|
8
|
+
id: 'e',
|
|
9
|
+
path: '.',
|
|
10
|
+
description: 'editor scope description here for tests',
|
|
11
|
+
layers: [{ id: 'l', name: 'L', description: 'a small layer for testing now', files: ['x'] } as any],
|
|
12
|
+
items,
|
|
13
|
+
}],
|
|
14
|
+
crossCutting: [],
|
|
15
|
+
narratives: { 'e/l': '# L' },
|
|
16
|
+
} as any;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const baseItem = (over: Partial<Item> = {}): Item => ({
|
|
20
|
+
id: 'ed-001',
|
|
21
|
+
title: 't',
|
|
22
|
+
layer: 'l',
|
|
23
|
+
status: 'now',
|
|
24
|
+
description: 'a roadmap item used for preflight tests now',
|
|
25
|
+
done_when: ['it works'],
|
|
26
|
+
goal_ref: ['g1'],
|
|
27
|
+
...over,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const warningsFor = (state: TenboState, id: string) =>
|
|
31
|
+
validate(state).warnings.filter((w) => w.itemId === id).map((w) => w.message);
|
|
32
|
+
|
|
33
|
+
describe('validator — preflight_violations (sk-029)', () => {
|
|
34
|
+
it('does not warn when item has no preflight_violations', () => {
|
|
35
|
+
const state = stateWithItems([baseItem()]);
|
|
36
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
37
|
+
expect(msgs.some((m) => m.includes('pre-flight violation'))).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('warns when an active item has an accepted pre-flight violation', () => {
|
|
41
|
+
const state = stateWithItems([baseItem({
|
|
42
|
+
preflight_violations: [{
|
|
43
|
+
check: 'ui-components anti-responsibility: no direct fs writes',
|
|
44
|
+
outcome: 'violation',
|
|
45
|
+
decision: 'accept-with-violation',
|
|
46
|
+
rationale: 'one-off debug helper',
|
|
47
|
+
}],
|
|
48
|
+
})]);
|
|
49
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
50
|
+
expect(msgs.some((m) => m.includes('accepted 1 pre-flight violation'))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not warn for done or dropped items even with accepted violations', () => {
|
|
54
|
+
for (const status of ['done', 'dropped'] as const) {
|
|
55
|
+
const state = stateWithItems([baseItem({
|
|
56
|
+
status,
|
|
57
|
+
preflight_violations: [{
|
|
58
|
+
check: 'ui-components anti-responsibility: no direct fs writes',
|
|
59
|
+
outcome: 'violation',
|
|
60
|
+
decision: 'accept-with-violation',
|
|
61
|
+
}],
|
|
62
|
+
})]);
|
|
63
|
+
const msgs = warningsFor(state, 'ed-001');
|
|
64
|
+
expect(msgs.some((m) => m.includes('pre-flight violation'))).toBe(false);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/api/lib/validator.ts
CHANGED
|
@@ -30,6 +30,39 @@ export function validate(state: TenboState): ValidateResult {
|
|
|
30
30
|
}
|
|
31
31
|
for (const item of state.crossCuttingRoadmap ?? []) allItemIds.add(item.id);
|
|
32
32
|
|
|
33
|
+
// Duplicate-ID detection (sk-031). Walks active + archived + cross-cutting
|
|
34
|
+
// items, recording every (id → location) occurrence. An ID appearing more
|
|
35
|
+
// than once anywhere in the workspace is a hard data-integrity error: it
|
|
36
|
+
// breaks relationship validation, history (active vs archive), and item
|
|
37
|
+
// identity. Emits one error per duplicated ID listing all locations.
|
|
38
|
+
const idOccurrences = new Map<string, string[]>();
|
|
39
|
+
const recordOccurrence = (id: string, where: string) => {
|
|
40
|
+
if (!id || typeof id !== 'string') return;
|
|
41
|
+
const arr = idOccurrences.get(id) ?? [];
|
|
42
|
+
arr.push(where);
|
|
43
|
+
idOccurrences.set(id, arr);
|
|
44
|
+
};
|
|
45
|
+
for (const scope of state.scopes) {
|
|
46
|
+
for (const item of scope.items) {
|
|
47
|
+
recordOccurrence(item.id, `scopes/${scope.id}/roadmap.yaml (active)`);
|
|
48
|
+
}
|
|
49
|
+
for (const item of scope.archivedItems ?? []) {
|
|
50
|
+
recordOccurrence(item.id, `scopes/${scope.id}/roadmap-archive.yaml`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const item of state.crossCuttingRoadmap ?? []) {
|
|
54
|
+
recordOccurrence(item.id, `.tenbo/roadmap.yaml (cross-cutting)`);
|
|
55
|
+
}
|
|
56
|
+
for (const [id, locs] of idOccurrences) {
|
|
57
|
+
if (locs.length > 1) {
|
|
58
|
+
errors.push({
|
|
59
|
+
level: 'error',
|
|
60
|
+
message: `duplicate item id "${id}" — appears in ${locs.join(' and ')}`,
|
|
61
|
+
itemId: id,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
33
66
|
const validateSupersededBy = (item: Item, scopeId?: string) => {
|
|
34
67
|
if (item.superseded_by === undefined) return;
|
|
35
68
|
const sb = item.superseded_by;
|
|
@@ -191,16 +224,12 @@ export function validate(state: TenboState): ValidateResult {
|
|
|
191
224
|
}
|
|
192
225
|
}
|
|
193
226
|
|
|
194
|
-
// Item rules
|
|
195
|
-
|
|
227
|
+
// Item rules. Note: cross-scope/active+archive duplicate-id detection runs
|
|
228
|
+
// once at the top of validate() (sk-031) — no per-scope seenIds needed.
|
|
196
229
|
for (const item of scope.items) {
|
|
197
230
|
if (!/^[a-z]{1,5}-\d{3,}$/.test(item.id)) {
|
|
198
231
|
errors.push({ level: 'error', message: `item "${item.id}" has invalid id format (expected <prefix>-NNN, e.g. ed-001 or x-001)`, scope: scope.id, itemId: item.id });
|
|
199
232
|
}
|
|
200
|
-
if (seenIds.has(item.id)) {
|
|
201
|
-
errors.push({ level: 'error', message: `duplicate item id "${item.id}"`, scope: scope.id, itemId: item.id });
|
|
202
|
-
}
|
|
203
|
-
seenIds.add(item.id);
|
|
204
233
|
// Status is optional when phases are present (it's derived via roll-up).
|
|
205
234
|
// Only validate when explicitly set.
|
|
206
235
|
const hasPhases = Array.isArray(item.phases) && item.phases.length > 0;
|
|
@@ -323,6 +352,45 @@ export function validate(state: TenboState): ValidateResult {
|
|
|
323
352
|
});
|
|
324
353
|
}
|
|
325
354
|
|
|
355
|
+
// Pre-flight violations (sk-029). Optional field; surface as a health
|
|
356
|
+
// observation (warning) when an item carries any accept-with-violation
|
|
357
|
+
// entry. Skip done/dropped items — no value nagging completed work.
|
|
358
|
+
// Shape-check only: validate array shape and enum values; don't
|
|
359
|
+
// cross-validate `check` strings against any taxonomy.
|
|
360
|
+
if (item.preflight_violations !== undefined) {
|
|
361
|
+
if (!Array.isArray(item.preflight_violations)) {
|
|
362
|
+
errors.push({ level: 'error', message: `item "${item.id}" preflight_violations must be a list`, scope: scope.id, itemId: item.id });
|
|
363
|
+
} else {
|
|
364
|
+
const VALID_OUTCOMES = new Set(['violation', 'threshold-cross', 'dependency-direction']);
|
|
365
|
+
const VALID_DECISIONS = new Set(['accept-with-violation', 'scope-adjusted', 'deferred']);
|
|
366
|
+
let acceptedCount = 0;
|
|
367
|
+
for (const entry of item.preflight_violations) {
|
|
368
|
+
if (!entry || typeof entry !== 'object') {
|
|
369
|
+
errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry is not an object`, scope: scope.id, itemId: item.id });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (typeof (entry as any).check !== 'string' || (entry as any).check.trim() === '') {
|
|
373
|
+
errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry missing string "check"`, scope: scope.id, itemId: item.id });
|
|
374
|
+
}
|
|
375
|
+
if (!VALID_OUTCOMES.has((entry as any).outcome)) {
|
|
376
|
+
errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry has invalid outcome "${(entry as any).outcome}"`, scope: scope.id, itemId: item.id });
|
|
377
|
+
}
|
|
378
|
+
if (!VALID_DECISIONS.has((entry as any).decision)) {
|
|
379
|
+
errors.push({ level: 'error', message: `item "${item.id}" preflight_violations entry has invalid decision "${(entry as any).decision}"`, scope: scope.id, itemId: item.id });
|
|
380
|
+
}
|
|
381
|
+
if ((entry as any).decision === 'accept-with-violation') acceptedCount++;
|
|
382
|
+
}
|
|
383
|
+
if (acceptedCount > 0 && item.status !== 'done' && item.status !== 'dropped') {
|
|
384
|
+
warnings.push({
|
|
385
|
+
level: 'warning',
|
|
386
|
+
message: `item ${item.id} has accepted ${acceptedCount} pre-flight violation(s) — review at next audit`,
|
|
387
|
+
scope: scope.id,
|
|
388
|
+
itemId: item.id,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
326
394
|
// Phase schema validation (Phase 3 of x-001).
|
|
327
395
|
if (item.phases !== undefined) {
|
|
328
396
|
if (!Array.isArray(item.phases)) {
|
package/src/types.ts
CHANGED
|
@@ -93,6 +93,18 @@ export interface Item {
|
|
|
93
93
|
* refactor, bug} lacks this field.
|
|
94
94
|
*/
|
|
95
95
|
doc_update?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Pre-flight violations recorded at capture time when the user accepted an
|
|
98
|
+
* item despite a flagged violation. Each entry: which check fired, what the
|
|
99
|
+
* outcome was, what the user decided, and their rationale. Empty / undefined
|
|
100
|
+
* when item passed pre-flight cleanly. See sk-029.
|
|
101
|
+
*/
|
|
102
|
+
preflight_violations?: Array<{
|
|
103
|
+
check: string; // e.g. "ui-components anti-responsibility: no direct fs writes"
|
|
104
|
+
outcome: 'violation' | 'threshold-cross' | 'dependency-direction';
|
|
105
|
+
decision: 'accept-with-violation' | 'scope-adjusted' | 'deferred';
|
|
106
|
+
rationale?: string;
|
|
107
|
+
}>;
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
export interface Layer {
|