pan-wizard 3.7.10 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/commands/pan/links.md +102 -0
- package/package.json +2 -2
- package/pan-wizard-core/bin/lib/codebase.cjs +2 -0
- package/pan-wizard-core/bin/lib/experiment.cjs +1 -0
- package/pan-wizard-core/bin/lib/links.cjs +549 -0
- package/pan-wizard-core/bin/lib/runner.cjs +1 -0
- package/pan-wizard-core/bin/lib/verify.cjs +23 -0
- package/pan-wizard-core/bin/pan-tools.cjs +25 -1
- package/scripts/git-hooks/pre-commit +40 -0
package/README.md
CHANGED
|
@@ -451,6 +451,24 @@ The orchestrator never does heavy lifting. It spawns agents, waits, integrates r
|
|
|
451
451
|
|
|
452
452
|
**The result:** You can run an entire phase — deep research, multiple plans created and verified, thousands of lines of code written across parallel executors, automated verification against goals — and your main context window stays at 30-40%. The work happens in fresh subagent contexts. Your session stays fast and responsive.
|
|
453
453
|
|
|
454
|
+
### Reasoning-Trace Handoff
|
|
455
|
+
|
|
456
|
+
When agents hand work off via files, only OUTPUTS get passed by default — not the reasoning that produced them. Per Cognition's "Don't build multi-agents" research (June 2025), silent decisions force downstream agents to reconcile contradictions blindly. PAN passes the reasoning explicitly:
|
|
457
|
+
|
|
458
|
+
- Plans carry a `## Plan Decisions` section (Locked / Open / Considered+rejected buckets) — the executor reads it before coding so it doesn't re-argue settled choices.
|
|
459
|
+
- Summaries carry an `## Implementation Decisions` section — the verifier reads it to understand WHY the executor deviated from the plan, not just THAT it did.
|
|
460
|
+
|
|
461
|
+
The plan-checker enforces this with two dedicated dimensions (Spec Sufficiency for Handoff, Decision Trace Completeness). Schema lives in `pan-wizard-core/references/handoff-decisions.md`.
|
|
462
|
+
|
|
463
|
+
### Self-Improving Learnings
|
|
464
|
+
|
|
465
|
+
PAN runs autonomous experiments in isolated folders, harvests the resulting telemetry, and promotes generalizable findings into a shipped patterns store at `pan-wizard-core/learnings/`:
|
|
466
|
+
|
|
467
|
+
- `learnings/universal/<topic>.md` — patterns that ship to every install (atomic-state, concurrency, idempotency, secret-handling, test-patterns, …). Loaded by planner / executor / verifier agents during their work.
|
|
468
|
+
- `learnings/internal/<topic>.md` — PAN-development patterns; source-only (stripped at install).
|
|
469
|
+
- `learnings/index.json` — topic→agent-relevance map. Workflows call `pan-tools learn topics-for --agent <role> --token-budget N` to load only relevant patterns instead of skim-everything (avoids the distractor-density anti-pattern).
|
|
470
|
+
- `pan-tools learn lint` — integrity check (duplicate IDs, dangling refs, scope leaks). Wired into `/check`.
|
|
471
|
+
|
|
454
472
|
### Atomic Git Commits
|
|
455
473
|
|
|
456
474
|
Each task gets its own commit immediately after completion:
|
|
@@ -553,7 +571,8 @@ PAN is not a replacement for your IDE or AI agent — it's the orchestration lay
|
|
|
553
571
|
| `/pan:todo-check` | List pending todos |
|
|
554
572
|
| `/pan:debug [desc]` | Systematic debugging with persistent state |
|
|
555
573
|
| `/pan:quick [--full]` | Execute ad-hoc task with PAN guarantees (`--full` adds plan-checking and verification) |
|
|
556
|
-
| `/pan:health [--repair] [--standards]` | Validate `.planning/` directory integrity
|
|
574
|
+
| `/pan:health [--repair] [--standards] [--full] [--drift] [--links]` | Validate `.planning/` directory integrity. `--repair` auto-fixes; `--standards` checks compliance; `--full` runs tests + build; `--drift` runs convention drift; `--links` attaches doc-code link-graph summary |
|
|
575
|
+
| `/pan:links [--strict]` | Validate the doc-code link graph: inline `[[<id>]]` refs, `// @pan:` source anchors, `require-code-mention` contracts (ADR-0027, v3.8.0+) |
|
|
557
576
|
| `/pan:phase-tests [N]` | Generate tests for a completed phase based on UAT criteria |
|
|
558
577
|
| `/pan:milestone-cleanup` | Archive accumulated phase directories from completed milestones |
|
|
559
578
|
| `/pan:retro` | Milestone retrospective — estimation accuracy, verification patterns, gap analysis |
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pan:links
|
|
3
|
+
group: Validation
|
|
4
|
+
description: Validate the doc-code link graph — inline wiki-style refs, source-comment anchors, and require-code-mention contracts (ADR-0027, v3.8.0+)
|
|
5
|
+
allowed-tools:
|
|
6
|
+
- Bash
|
|
7
|
+
- Read
|
|
8
|
+
- Grep
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# /pan:links
|
|
12
|
+
|
|
13
|
+
Validate the doc-code link graph. Walks `docs/`, `pan-wizard-core/`, `commands/`, and `agents/` for inline `[[<id>]]` references and `// @pan: <id>` source-comment anchors. Reports broken refs, stale anchors, and uncovered backlink contracts.
|
|
14
|
+
|
|
15
|
+
**Usage:**
|
|
16
|
+
```
|
|
17
|
+
/pan:links
|
|
18
|
+
/pan:links --strict
|
|
19
|
+
/pan:links --doc-root <path> [--doc-root <path>...]
|
|
20
|
+
/pan:links --source-root <path> [--source-root <path>...]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Flags:**
|
|
24
|
+
- `--strict` — fail (exit 1) on warnings, not only errors. Default is advisory: warnings do not flip status.
|
|
25
|
+
- `--doc-root <path>` — override default doc roots. Repeatable.
|
|
26
|
+
- `--source-root <path>` — override default source roots. Repeatable.
|
|
27
|
+
- `--raw` — human-readable output instead of JSON.
|
|
28
|
+
|
|
29
|
+
**What it does:**
|
|
30
|
+
|
|
31
|
+
Three sequential passes share one walk pair:
|
|
32
|
+
|
|
33
|
+
1. **Forward links** — every `[[<id>]]` in body text and every `must_haves.key_links` entry must resolve. Section anchors (`[[ADR-0021#Decision]]`) check that the named heading exists.
|
|
34
|
+
2. **Backlink contract** — docs with `require-code-mention: true` in frontmatter must have at least one `@pan:` source anchor that resolves to them.
|
|
35
|
+
3. **Anchor-target existence** — every `// @pan: <id>` comment must point to a real doc.
|
|
36
|
+
|
|
37
|
+
**Doc-id forms accepted:**
|
|
38
|
+
|
|
39
|
+
- `ADR-NNNN` — resolves via glob to `docs/decisions/ADR-NNNN-*.md`
|
|
40
|
+
- `<path>.md` — exact path relative to repo root
|
|
41
|
+
- `<path>` (no extension) — tries `<path>.md` then `<path>/README.md`
|
|
42
|
+
- Any of the above with `#section` — verifies a heading whose slug matches
|
|
43
|
+
|
|
44
|
+
**Source-anchor grammar:**
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
// @pan: ADR-0027 (JS / TS / CJS)
|
|
48
|
+
# @pan: ADR-0027 (Python / shell)
|
|
49
|
+
<!-- @pan: ADR-0027 --> (Markdown / HTML)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Anchors cluster at the top of a file under a single banner; comment leader must be the line's first non-whitespace token.
|
|
53
|
+
|
|
54
|
+
**Exit codes:**
|
|
55
|
+
|
|
56
|
+
- `0` — pass
|
|
57
|
+
- `1` — fail (errors present, or warnings present under `--strict`)
|
|
58
|
+
|
|
59
|
+
**Output (JSON):**
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"ok": true,
|
|
64
|
+
"summary": {
|
|
65
|
+
"total_findings": 0,
|
|
66
|
+
"errors": 0,
|
|
67
|
+
"warnings": 0,
|
|
68
|
+
"status": "pass",
|
|
69
|
+
"doc_files_scanned": 280,
|
|
70
|
+
"source_files_scanned": 170,
|
|
71
|
+
"anchors_found": 4,
|
|
72
|
+
"forward_links_found": 12,
|
|
73
|
+
"backlink_contracts_checked": 3
|
|
74
|
+
},
|
|
75
|
+
"findings": []
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Finding codes:**
|
|
80
|
+
|
|
81
|
+
| Code | Severity | Meaning |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| F-001 | error | Inline `[[<id>]]` does not resolve |
|
|
84
|
+
| F-002 | error | `[[<doc>#<section>]]` resolves the file but the section is missing |
|
|
85
|
+
| F-003 | warning | `must_haves.key_links` entry's `from` or `to` does not exist |
|
|
86
|
+
| F-004 | warning | `must_haves.key_links` regex pattern is invalid |
|
|
87
|
+
| B-001 | error | Doc has `require-code-mention: true` but no `@pan:` anchors resolve to it |
|
|
88
|
+
| B-002 | warning | Doc is anchored by exactly one source file (single-source informational) |
|
|
89
|
+
| A-001 | error | `@pan:` anchor target does not resolve |
|
|
90
|
+
| A-002 | warning | `@pan:` anchor section is missing in the resolved file |
|
|
91
|
+
| A-004 | warning | `@pan:` anchor has empty id |
|
|
92
|
+
|
|
93
|
+
**Composing with `validate health`:**
|
|
94
|
+
|
|
95
|
+
`validate health --links` includes the link-graph summary as a `link_graph` field in the health report. Used as a pre-flight check before release. Errors degrade the health report to a warning-level issue (`LINKS_ERR`); non-blocking unless `--strict` is added to a separate `links validate` invocation.
|
|
96
|
+
|
|
97
|
+
**See also:**
|
|
98
|
+
|
|
99
|
+
- ADR-0027 — Doc–Code Link Graph
|
|
100
|
+
- `docs/specs/doc_code_link_graph_featureai.md` — wire-level spec
|
|
101
|
+
- `pan-tools doc-lint` — frontmatter schema validator (orthogonal concern)
|
|
102
|
+
- `pan-tools verify-key-links` — legacy frontmatter-only link verifier (subsumed; both still ship)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "A lightweight workflow automation and context engineering system for Claude Code, OpenCode, Gemini CLI, Codex, and Copilot CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@playwright/test": "^1.58.2",
|
|
53
53
|
"@vscode/test-electron": "^2.5.2",
|
|
54
|
-
"esbuild": "^0.
|
|
54
|
+
"esbuild": "^0.28.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build:hooks": "node scripts/build-hooks.js",
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// @pan: ADR-0027
|
|
3
|
+
/**
|
|
4
|
+
* Links — Doc–Code link graph scanner and lint.
|
|
5
|
+
*
|
|
6
|
+
* Implements ADR-0027 (Doc–Code Link Graph).
|
|
7
|
+
* Spec: docs/specs/doc_code_link_graph_featureai.md
|
|
8
|
+
*
|
|
9
|
+
* Three lint passes share one walk pair:
|
|
10
|
+
* - Forward links: inline [[<id>]] in body + must_haves.key_links in frontmatter.
|
|
11
|
+
* - Backlink contract: docs with `require-code-mention: true` must have at
|
|
12
|
+
* least one resolving @pan: anchor.
|
|
13
|
+
* - Anchor-target existence: every @pan: anchor must resolve to a real doc.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const { safeReadFile, toPosix, output } = require('./core.cjs');
|
|
19
|
+
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
|
20
|
+
const { walkMarkdownFiles } = require('./doc-lint/walk.js');
|
|
21
|
+
|
|
22
|
+
const DEFAULT_DOC_ROOTS = [
|
|
23
|
+
'docs',
|
|
24
|
+
'pan-wizard-core/workflows',
|
|
25
|
+
'pan-wizard-core/templates',
|
|
26
|
+
'pan-wizard-core/references',
|
|
27
|
+
'pan-wizard-core/learnings',
|
|
28
|
+
'commands',
|
|
29
|
+
'agents',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const DEFAULT_SOURCE_ROOTS = [
|
|
33
|
+
'pan-wizard-core',
|
|
34
|
+
'bin',
|
|
35
|
+
'hooks',
|
|
36
|
+
'scripts',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const SOURCE_EXT_TO_LEADER = {
|
|
40
|
+
'.cjs': '//',
|
|
41
|
+
'.js': '//',
|
|
42
|
+
'.mjs': '//',
|
|
43
|
+
'.ts': '//',
|
|
44
|
+
'.sh': '#',
|
|
45
|
+
'.py': '#',
|
|
46
|
+
'.ps1': '#',
|
|
47
|
+
'.md': '<!--',
|
|
48
|
+
'.html': '<!--',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ANCHOR_RES = {
|
|
52
|
+
'//': /^\s*\/\/\s*@pan:\s*([^\s].*?)\s*$/,
|
|
53
|
+
'#': /^\s*#\s*@pan:\s*([^\s].*?)\s*$/,
|
|
54
|
+
'<!--': /^\s*<!--\s*@pan:\s*([^\s].*?)\s*(?:-->)?\s*$/,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const INLINE_LINK_RE = /\[\[([^\[\]\s|][^\[\]]*?)\]\]/g;
|
|
58
|
+
const ADR_SHORT_RE = /^ADR-(\d{4})$/i;
|
|
59
|
+
|
|
60
|
+
const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', '.cache', 'coverage']);
|
|
61
|
+
|
|
62
|
+
// ─── Doc-id resolver ─────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function slugify(s) {
|
|
65
|
+
return String(s).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function fileHasSection(filePath, section) {
|
|
69
|
+
const content = safeReadFile(filePath);
|
|
70
|
+
if (!content) return false;
|
|
71
|
+
const target = slugify(section);
|
|
72
|
+
const lines = content.split('\n');
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const m = line.match(/^#{1,6}\s+(.+?)\s*$/);
|
|
75
|
+
if (m && slugify(m[1]) === target) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveDocId(rawId, cwd) {
|
|
81
|
+
if (!rawId || !rawId.trim()) return { resolved: false, reason: 'empty id' };
|
|
82
|
+
let id = rawId.trim();
|
|
83
|
+
let section = null;
|
|
84
|
+
const hashIdx = id.indexOf('#');
|
|
85
|
+
if (hashIdx !== -1) {
|
|
86
|
+
section = id.slice(hashIdx + 1).trim();
|
|
87
|
+
id = id.slice(0, hashIdx).trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ADR-NNNN shortcut → glob docs/decisions/ADR-NNNN-*.md
|
|
91
|
+
const adrMatch = id.match(ADR_SHORT_RE);
|
|
92
|
+
if (adrMatch) {
|
|
93
|
+
const num = adrMatch[1];
|
|
94
|
+
const decisionsDir = path.join(cwd, 'docs', 'decisions');
|
|
95
|
+
let entries = [];
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(decisionsDir);
|
|
98
|
+
} catch {
|
|
99
|
+
return { resolved: false, reason: 'docs/decisions/ not found' };
|
|
100
|
+
}
|
|
101
|
+
const candidates = entries.filter(f =>
|
|
102
|
+
f.toLowerCase().startsWith(`adr-${num}-`) && f.endsWith('.md')
|
|
103
|
+
);
|
|
104
|
+
if (candidates.length === 0) {
|
|
105
|
+
return { resolved: false, reason: `no ADR-${num}-*.md found` };
|
|
106
|
+
}
|
|
107
|
+
if (candidates.length > 1) {
|
|
108
|
+
return { resolved: false, reason: `ambiguous ADR-${num}: ${candidates.join(', ')}` };
|
|
109
|
+
}
|
|
110
|
+
const relPath = toPosix(path.join('docs', 'decisions', candidates[0]));
|
|
111
|
+
if (section) {
|
|
112
|
+
const fullPath = path.join(cwd, 'docs', 'decisions', candidates[0]);
|
|
113
|
+
if (fileHasSection(fullPath, section)) {
|
|
114
|
+
return { resolved: true, path: relPath, section };
|
|
115
|
+
}
|
|
116
|
+
return { resolved: true, path: relPath, section, sectionMissing: true };
|
|
117
|
+
}
|
|
118
|
+
return { resolved: true, path: relPath };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Direct .md path
|
|
122
|
+
if (id.endsWith('.md')) {
|
|
123
|
+
const fullPath = path.join(cwd, id);
|
|
124
|
+
try { fs.accessSync(fullPath); }
|
|
125
|
+
catch { return { resolved: false, reason: `${id} not found` }; }
|
|
126
|
+
if (section) {
|
|
127
|
+
if (fileHasSection(fullPath, section)) {
|
|
128
|
+
return { resolved: true, path: toPosix(id), section };
|
|
129
|
+
}
|
|
130
|
+
return { resolved: true, path: toPosix(id), section, sectionMissing: true };
|
|
131
|
+
}
|
|
132
|
+
return { resolved: true, path: toPosix(id) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Try <id>.md, then <id>/README.md
|
|
136
|
+
const candidates = [`${id}.md`, path.join(id, 'README.md')];
|
|
137
|
+
for (const cand of candidates) {
|
|
138
|
+
const fullPath = path.join(cwd, cand);
|
|
139
|
+
try {
|
|
140
|
+
fs.accessSync(fullPath);
|
|
141
|
+
const relCand = toPosix(cand);
|
|
142
|
+
if (section) {
|
|
143
|
+
if (fileHasSection(fullPath, section)) {
|
|
144
|
+
return { resolved: true, path: relCand, section };
|
|
145
|
+
}
|
|
146
|
+
return { resolved: true, path: relCand, section, sectionMissing: true };
|
|
147
|
+
}
|
|
148
|
+
return { resolved: true, path: relCand };
|
|
149
|
+
} catch { /* try next */ }
|
|
150
|
+
}
|
|
151
|
+
return { resolved: false, reason: `${id} (tried ${id}.md and ${id}/README.md)` };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Forward-link scanner ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function stripInlineCodeSpans(line) {
|
|
157
|
+
// Replace `...` spans (and ``...`` etc.) with placeholders so [[...]] inside
|
|
158
|
+
// backticks is not picked up as a real link.
|
|
159
|
+
return line.replace(/(`+)([^`]|(?!\1)`)*?\1/g, m => ' '.repeat(m.length));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseInlineLinks(text) {
|
|
163
|
+
const out = [];
|
|
164
|
+
const lines = text.split('\n');
|
|
165
|
+
let inFence = false;
|
|
166
|
+
let fenceMarker = '';
|
|
167
|
+
let inFrontmatter = false;
|
|
168
|
+
let frontmatterDone = false;
|
|
169
|
+
for (let i = 0; i < lines.length; i++) {
|
|
170
|
+
const line = lines[i];
|
|
171
|
+
// Skip leading YAML frontmatter (--- on line 1, then content, then closing ---).
|
|
172
|
+
// Only the leading block; subsequent --- in body is unaffected.
|
|
173
|
+
if (i === 0 && line.trim() === '---') { inFrontmatter = true; continue; }
|
|
174
|
+
if (inFrontmatter && !frontmatterDone) {
|
|
175
|
+
if (line.trim() === '---') { inFrontmatter = false; frontmatterDone = true; }
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Toggle fenced-code-block state on lines opening/closing ``` or ~~~
|
|
179
|
+
const fenceMatch = line.match(/^(\s{0,3})(```+|~~~+)(.*)$/);
|
|
180
|
+
if (fenceMatch) {
|
|
181
|
+
const marker = fenceMatch[2];
|
|
182
|
+
if (!inFence) { inFence = true; fenceMarker = marker[0]; continue; }
|
|
183
|
+
if (marker[0] === fenceMarker) { inFence = false; fenceMarker = ''; continue; }
|
|
184
|
+
}
|
|
185
|
+
if (inFence) continue;
|
|
186
|
+
const stripped = stripInlineCodeSpans(line);
|
|
187
|
+
INLINE_LINK_RE.lastIndex = 0;
|
|
188
|
+
let m;
|
|
189
|
+
while ((m = INLINE_LINK_RE.exec(stripped)) !== null) {
|
|
190
|
+
out.push({ rawId: m[1].trim(), line: i + 1 });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeWalkDocs(rootAbs) {
|
|
197
|
+
try {
|
|
198
|
+
return walkMarkdownFiles(rootAbs, { exclude: ['**/node_modules/**'] });
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scanForwardLinks(docRoots, cwd) {
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const root of docRoots) {
|
|
207
|
+
const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
208
|
+
const files = safeWalkDocs(fullDir);
|
|
209
|
+
if (!files) continue;
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
if (file.readError) continue;
|
|
212
|
+
const relPath = toPosix(path.relative(cwd, file.path));
|
|
213
|
+
for (const link of parseInlineLinks(file.content)) {
|
|
214
|
+
out.push({
|
|
215
|
+
source: relPath,
|
|
216
|
+
sourceLine: link.line,
|
|
217
|
+
rawId: link.rawId,
|
|
218
|
+
via: 'inline',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const keyLinks = parseMustHavesBlock(file.content, 'key_links');
|
|
223
|
+
for (const link of keyLinks) {
|
|
224
|
+
if (typeof link === 'string') continue;
|
|
225
|
+
out.push({
|
|
226
|
+
source: relPath,
|
|
227
|
+
sourceLine: 0,
|
|
228
|
+
rawId: link.to || '',
|
|
229
|
+
via: 'key_links',
|
|
230
|
+
from: link.from || '',
|
|
231
|
+
pattern: link.pattern || '',
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
} catch { /* malformed frontmatter — skip */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Source-anchor scanner ───────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function leaderForFile(filePath) {
|
|
243
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
244
|
+
return SOURCE_EXT_TO_LEADER[ext] || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function parseAnchorLine(line, leader) {
|
|
248
|
+
const re = ANCHOR_RES[leader];
|
|
249
|
+
if (!re) return null;
|
|
250
|
+
const m = line.match(re);
|
|
251
|
+
return m ? m[1].trim() : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function walkSourceFiles(rootDir, out) {
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
258
|
+
} catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (entry.isDirectory()) {
|
|
263
|
+
if (SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
264
|
+
walkSourceFiles(path.join(rootDir, entry.name), out);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!entry.isFile()) continue;
|
|
268
|
+
const leader = leaderForFile(entry.name);
|
|
269
|
+
if (!leader) continue;
|
|
270
|
+
out.push({ path: path.join(rootDir, entry.name), leader });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function scanAnchors(sourceRoots, cwd) {
|
|
275
|
+
const out = [];
|
|
276
|
+
const files = [];
|
|
277
|
+
for (const root of sourceRoots) {
|
|
278
|
+
const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
279
|
+
walkSourceFiles(fullDir, files);
|
|
280
|
+
}
|
|
281
|
+
for (const { path: fp, leader } of files) {
|
|
282
|
+
const content = safeReadFile(fp);
|
|
283
|
+
if (!content) continue;
|
|
284
|
+
const lines = content.split('\n');
|
|
285
|
+
const relPath = toPosix(path.relative(cwd, fp));
|
|
286
|
+
for (let i = 0; i < lines.length; i++) {
|
|
287
|
+
const id = parseAnchorLine(lines[i], leader);
|
|
288
|
+
if (id !== null) {
|
|
289
|
+
out.push({ source: relPath, sourceLine: i + 1, rawId: id, leader });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Lint passes ─────────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
function runForwardPass(forwardLinks, cwd) {
|
|
299
|
+
const findings = [];
|
|
300
|
+
for (const link of forwardLinks) {
|
|
301
|
+
if (link.via === 'inline') {
|
|
302
|
+
const r = resolveDocId(link.rawId, cwd);
|
|
303
|
+
if (!r.resolved) {
|
|
304
|
+
findings.push({
|
|
305
|
+
code: 'F-001',
|
|
306
|
+
severity: 'error',
|
|
307
|
+
source: link.source,
|
|
308
|
+
source_line: link.sourceLine,
|
|
309
|
+
target: link.rawId,
|
|
310
|
+
detail: r.reason || 'unresolved',
|
|
311
|
+
});
|
|
312
|
+
} else if (r.sectionMissing) {
|
|
313
|
+
findings.push({
|
|
314
|
+
code: 'F-002',
|
|
315
|
+
severity: 'error',
|
|
316
|
+
source: link.source,
|
|
317
|
+
source_line: link.sourceLine,
|
|
318
|
+
target: link.rawId,
|
|
319
|
+
detail: `Section "#${r.section}" not found in ${r.path}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (link.via === 'key_links') {
|
|
325
|
+
if (link.from) {
|
|
326
|
+
try { fs.accessSync(path.join(cwd, link.from)); }
|
|
327
|
+
catch {
|
|
328
|
+
findings.push({
|
|
329
|
+
code: 'F-003', severity: 'warning',
|
|
330
|
+
source: link.source, source_line: 0, target: link.from,
|
|
331
|
+
detail: `key_links.from path does not exist: ${link.from}`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (link.rawId) {
|
|
336
|
+
try { fs.accessSync(path.join(cwd, link.rawId)); }
|
|
337
|
+
catch {
|
|
338
|
+
findings.push({
|
|
339
|
+
code: 'F-003', severity: 'warning',
|
|
340
|
+
source: link.source, source_line: 0, target: link.rawId,
|
|
341
|
+
detail: `key_links.to path does not exist: ${link.rawId}`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (link.pattern) {
|
|
346
|
+
try { new RegExp(link.pattern); }
|
|
347
|
+
catch (e) {
|
|
348
|
+
findings.push({
|
|
349
|
+
code: 'F-004', severity: 'warning',
|
|
350
|
+
source: link.source, source_line: 0, target: link.rawId,
|
|
351
|
+
detail: `Invalid regex in key_links.pattern: ${e.message}`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return findings;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function runBacklinkPass(docRoots, anchors, cwd) {
|
|
361
|
+
const findings = [];
|
|
362
|
+
|
|
363
|
+
// Index: resolved doc path → array of anchor source files
|
|
364
|
+
const anchorIdx = new Map();
|
|
365
|
+
for (const a of anchors) {
|
|
366
|
+
const r = resolveDocId(a.rawId, cwd);
|
|
367
|
+
if (!r.resolved) continue;
|
|
368
|
+
if (!anchorIdx.has(r.path)) anchorIdx.set(r.path, []);
|
|
369
|
+
anchorIdx.get(r.path).push(a.source);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const root of docRoots) {
|
|
373
|
+
const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
374
|
+
const files = safeWalkDocs(fullDir);
|
|
375
|
+
if (!files) continue;
|
|
376
|
+
for (const file of files) {
|
|
377
|
+
if (file.readError) continue;
|
|
378
|
+
const fm = extractFrontmatter(file.content);
|
|
379
|
+
const requireMention = fm['require-code-mention'];
|
|
380
|
+
if (requireMention !== true && requireMention !== 'true') continue;
|
|
381
|
+
const relPath = toPosix(path.relative(cwd, file.path));
|
|
382
|
+
const sources = anchorIdx.get(relPath) || [];
|
|
383
|
+
if (sources.length === 0) {
|
|
384
|
+
findings.push({
|
|
385
|
+
code: 'B-001', severity: 'error',
|
|
386
|
+
source: relPath, source_line: 0, target: null,
|
|
387
|
+
detail: 'require-code-mention is true but no @pan: anchors resolve to this doc',
|
|
388
|
+
});
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const unique = new Set(sources);
|
|
392
|
+
if (unique.size === 1) {
|
|
393
|
+
findings.push({
|
|
394
|
+
code: 'B-002', severity: 'warning',
|
|
395
|
+
source: relPath, source_line: 0, target: [...unique][0],
|
|
396
|
+
detail: `Only one source file anchors this doc (${[...unique][0]})`,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return findings;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function runAnchorTargetPass(anchors, cwd) {
|
|
405
|
+
const findings = [];
|
|
406
|
+
for (const a of anchors) {
|
|
407
|
+
if (!a.rawId) {
|
|
408
|
+
findings.push({
|
|
409
|
+
code: 'A-004', severity: 'warning',
|
|
410
|
+
source: a.source, source_line: a.sourceLine,
|
|
411
|
+
target: null, detail: '@pan: anchor has empty id',
|
|
412
|
+
});
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const r = resolveDocId(a.rawId, cwd);
|
|
416
|
+
if (!r.resolved) {
|
|
417
|
+
findings.push({
|
|
418
|
+
code: 'A-001', severity: 'error',
|
|
419
|
+
source: a.source, source_line: a.sourceLine,
|
|
420
|
+
target: a.rawId, detail: r.reason || 'unresolved',
|
|
421
|
+
});
|
|
422
|
+
} else if (r.sectionMissing) {
|
|
423
|
+
findings.push({
|
|
424
|
+
code: 'A-002', severity: 'warning',
|
|
425
|
+
source: a.source, source_line: a.sourceLine,
|
|
426
|
+
target: a.rawId,
|
|
427
|
+
detail: `Section "#${r.section}" not found in ${r.path}`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return findings;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function countBacklinkContracts(docRoots, cwd) {
|
|
435
|
+
let n = 0;
|
|
436
|
+
for (const root of docRoots) {
|
|
437
|
+
const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
438
|
+
const files = safeWalkDocs(fullDir);
|
|
439
|
+
if (!files) continue;
|
|
440
|
+
for (const file of files) {
|
|
441
|
+
if (file.readError) continue;
|
|
442
|
+
const fm = extractFrontmatter(file.content);
|
|
443
|
+
const v = fm['require-code-mention'];
|
|
444
|
+
if (v === true || v === 'true') n++;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return n;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function countDocFiles(docRoots, cwd) {
|
|
451
|
+
let n = 0;
|
|
452
|
+
for (const root of docRoots) {
|
|
453
|
+
const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
454
|
+
const files = safeWalkDocs(fullDir);
|
|
455
|
+
if (files) n += files.length;
|
|
456
|
+
}
|
|
457
|
+
return n;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function countSourceFiles(sourceRoots, cwd) {
|
|
461
|
+
const acc = [];
|
|
462
|
+
for (const root of sourceRoots) {
|
|
463
|
+
const fullDir = path.isAbsolute(root) ? root : path.join(cwd, root);
|
|
464
|
+
walkSourceFiles(fullDir, acc);
|
|
465
|
+
}
|
|
466
|
+
return acc.length;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ─── Top-level validateAll ───────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
function validateAll(cwd, opts = {}) {
|
|
472
|
+
const docRoots = opts.docRoots || DEFAULT_DOC_ROOTS;
|
|
473
|
+
const sourceRoots = opts.sourceRoots || DEFAULT_SOURCE_ROOTS;
|
|
474
|
+
const strict = !!opts.strict;
|
|
475
|
+
|
|
476
|
+
const forwardLinks = scanForwardLinks(docRoots, cwd);
|
|
477
|
+
const anchors = scanAnchors(sourceRoots, cwd);
|
|
478
|
+
|
|
479
|
+
const findings = [];
|
|
480
|
+
findings.push(...runForwardPass(forwardLinks, cwd));
|
|
481
|
+
findings.push(...runBacklinkPass(docRoots, anchors, cwd));
|
|
482
|
+
findings.push(...runAnchorTargetPass(anchors, cwd));
|
|
483
|
+
|
|
484
|
+
const errors = findings.filter(f => f.severity === 'error').length;
|
|
485
|
+
const warnings = findings.filter(f => f.severity === 'warning').length;
|
|
486
|
+
// Per spec §5.2: B-002 is informational and does not flip status under --strict.
|
|
487
|
+
const strictWarnings = findings.filter(f => f.severity === 'warning' && f.code !== 'B-002').length;
|
|
488
|
+
let status;
|
|
489
|
+
if (errors > 0) status = 'fail';
|
|
490
|
+
else if (strict && strictWarnings > 0) status = 'fail';
|
|
491
|
+
else status = 'pass';
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
ok: status === 'pass',
|
|
495
|
+
summary: {
|
|
496
|
+
total_findings: findings.length,
|
|
497
|
+
errors,
|
|
498
|
+
warnings,
|
|
499
|
+
status,
|
|
500
|
+
doc_files_scanned: countDocFiles(docRoots, cwd),
|
|
501
|
+
source_files_scanned: countSourceFiles(sourceRoots, cwd),
|
|
502
|
+
anchors_found: anchors.length,
|
|
503
|
+
forward_links_found: forwardLinks.length,
|
|
504
|
+
backlink_contracts_checked: countBacklinkContracts(docRoots, cwd),
|
|
505
|
+
},
|
|
506
|
+
findings,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function cmdLinksValidate(cwd, opts = {}) {
|
|
511
|
+
const result = validateAll(cwd, opts);
|
|
512
|
+
// Bypass core.output() because it unconditionally exits 0; we need exit 1
|
|
513
|
+
// when status is "fail" so CI / hooks can detect violations.
|
|
514
|
+
if (opts.raw) {
|
|
515
|
+
const lines = [
|
|
516
|
+
`Links: ${result.summary.status.toUpperCase()}`,
|
|
517
|
+
``,
|
|
518
|
+
`Doc files scanned: ${result.summary.doc_files_scanned}`,
|
|
519
|
+
`Source files scanned: ${result.summary.source_files_scanned}`,
|
|
520
|
+
`Forward links: ${result.summary.forward_links_found}`,
|
|
521
|
+
`Anchors: ${result.summary.anchors_found}`,
|
|
522
|
+
`Backlink contracts: ${result.summary.backlink_contracts_checked}`,
|
|
523
|
+
``,
|
|
524
|
+
`Errors: ${result.summary.errors}`,
|
|
525
|
+
`Warnings: ${result.summary.warnings}`,
|
|
526
|
+
``,
|
|
527
|
+
];
|
|
528
|
+
for (const f of result.findings) {
|
|
529
|
+
const where = f.source_line ? `${f.source}:${f.source_line}` : f.source;
|
|
530
|
+
lines.push(`[${f.severity.toUpperCase()}] ${f.code} ${where}: ${f.detail}`);
|
|
531
|
+
}
|
|
532
|
+
process.stdout.write(lines.join('\n'));
|
|
533
|
+
} else {
|
|
534
|
+
process.stdout.write(JSON.stringify(result, null, 2));
|
|
535
|
+
}
|
|
536
|
+
process.exit(result.summary.status === 'fail' ? 1 : 0);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
module.exports = {
|
|
540
|
+
validateAll,
|
|
541
|
+
cmdLinksValidate,
|
|
542
|
+
scanForwardLinks,
|
|
543
|
+
scanAnchors,
|
|
544
|
+
resolveDocId,
|
|
545
|
+
parseAnchorLine,
|
|
546
|
+
parseInlineLinks,
|
|
547
|
+
DEFAULT_DOC_ROOTS,
|
|
548
|
+
DEFAULT_SOURCE_ROOTS,
|
|
549
|
+
};
|
|
@@ -1215,6 +1215,26 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1215
1215
|
}
|
|
1216
1216
|
}
|
|
1217
1217
|
|
|
1218
|
+
// Check 12 (optional): doc-code link graph (ADR-0027)
|
|
1219
|
+
let linkGraphResult;
|
|
1220
|
+
if (options.links) {
|
|
1221
|
+
const links = require('./links.cjs');
|
|
1222
|
+
const r = links.validateAll(cwd);
|
|
1223
|
+
linkGraphResult = {
|
|
1224
|
+
status: r.summary.status,
|
|
1225
|
+
errors: r.summary.errors,
|
|
1226
|
+
warnings: r.summary.warnings,
|
|
1227
|
+
doc_files_scanned: r.summary.doc_files_scanned,
|
|
1228
|
+
source_files_scanned: r.summary.source_files_scanned,
|
|
1229
|
+
anchors_found: r.summary.anchors_found,
|
|
1230
|
+
forward_links_found: r.summary.forward_links_found,
|
|
1231
|
+
backlink_contracts_checked: r.summary.backlink_contracts_checked,
|
|
1232
|
+
};
|
|
1233
|
+
if (r.summary.errors > 0) {
|
|
1234
|
+
addIssue('warning', 'LINKS_ERR', `Link graph has ${r.summary.errors} errors (broken refs or uncovered backlink contracts)`, 'Run pan-tools links validate for details');
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1218
1238
|
const result = {
|
|
1219
1239
|
status,
|
|
1220
1240
|
errors,
|
|
@@ -1230,6 +1250,9 @@ function cmdValidateHealth(cwd, options, raw) {
|
|
|
1230
1250
|
if (options.drift) {
|
|
1231
1251
|
result.drift_status = driftResult;
|
|
1232
1252
|
}
|
|
1253
|
+
if (options.links) {
|
|
1254
|
+
result.link_graph = linkGraphResult;
|
|
1255
|
+
}
|
|
1233
1256
|
|
|
1234
1257
|
output(result, raw);
|
|
1235
1258
|
}
|
|
@@ -214,6 +214,7 @@ const runner = require('./lib/runner.cjs');
|
|
|
214
214
|
const docLint = require('./lib/doc-lint.cjs');
|
|
215
215
|
const learnLint = require('./lib/learn-lint.cjs');
|
|
216
216
|
const learnIndex = require('./lib/learn-index.cjs');
|
|
217
|
+
const links = require('./lib/links.cjs');
|
|
217
218
|
|
|
218
219
|
/**
|
|
219
220
|
* Get the value following a flag in the args array.
|
|
@@ -663,7 +664,8 @@ async function main() {
|
|
|
663
664
|
const standardsFlag = args.includes('--standards');
|
|
664
665
|
const fullFlag = args.includes('--full');
|
|
665
666
|
const driftFlag = args.includes('--drift');
|
|
666
|
-
|
|
667
|
+
const linksFlag = args.includes('--links');
|
|
668
|
+
verify.cmdValidateHealth(cwd, { repair: repairFlag, standards: standardsFlag, full: fullFlag, drift: driftFlag, links: linksFlag }, raw);
|
|
667
669
|
} else if (subcommand === 'deployment') {
|
|
668
670
|
verify.cmdValidateDeployment(cwd, raw);
|
|
669
671
|
} else {
|
|
@@ -1307,6 +1309,28 @@ async function main() {
|
|
|
1307
1309
|
break;
|
|
1308
1310
|
}
|
|
1309
1311
|
|
|
1312
|
+
case 'links': {
|
|
1313
|
+
const subcommand = args[1];
|
|
1314
|
+
if (subcommand === 'validate' || !subcommand) {
|
|
1315
|
+
const collectMulti = (flag) => {
|
|
1316
|
+
const vals = [];
|
|
1317
|
+
for (let i = 0; i < args.length; i++) {
|
|
1318
|
+
if (args[i] === flag && i + 1 < args.length) vals.push(args[i + 1]);
|
|
1319
|
+
}
|
|
1320
|
+
return vals.length ? vals : null;
|
|
1321
|
+
};
|
|
1322
|
+
const opts = {
|
|
1323
|
+
docRoots: collectMulti('--doc-root'),
|
|
1324
|
+
sourceRoots: collectMulti('--source-root'),
|
|
1325
|
+
strict: args.includes('--strict'),
|
|
1326
|
+
raw,
|
|
1327
|
+
};
|
|
1328
|
+
links.cmdLinksValidate(cwd, opts);
|
|
1329
|
+
break;
|
|
1330
|
+
}
|
|
1331
|
+
error(`Unknown links subcommand: ${subcommand}. Available: validate`);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1310
1334
|
default:
|
|
1311
1335
|
error(`Unknown command: ${command}. Run pan-tools without arguments to see available commands.`);
|
|
1312
1336
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# PAN Wizard pre-commit hook.
|
|
3
|
+
#
|
|
4
|
+
# Runs `gitleaks protect` against staged changes. Blocks the commit if a
|
|
5
|
+
# secret is detected. The PAN allowlists in .gitleaks.toml are honoured.
|
|
6
|
+
#
|
|
7
|
+
# Install once per clone:
|
|
8
|
+
# cp scripts/git-hooks/pre-commit .git/hooks/pre-commit
|
|
9
|
+
# chmod +x .git/hooks/pre-commit
|
|
10
|
+
# Or with a symlink (Unix / Git Bash):
|
|
11
|
+
# ln -sf ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit
|
|
12
|
+
#
|
|
13
|
+
# Bypass for an emergency (creates a paper trail in the commit log):
|
|
14
|
+
# SKIP_GITLEAKS=1 git commit -m "..."
|
|
15
|
+
#
|
|
16
|
+
# Exit codes:
|
|
17
|
+
# 0 ok — no secrets detected (or gitleaks not installed; we don't block).
|
|
18
|
+
# 1 gitleaks found something — fix the staged change before committing.
|
|
19
|
+
|
|
20
|
+
set -e
|
|
21
|
+
|
|
22
|
+
if [ "${SKIP_GITLEAKS:-}" = "1" ]; then
|
|
23
|
+
echo "[pre-commit] SKIP_GITLEAKS=1 — gitleaks bypassed."
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
if ! command -v gitleaks >/dev/null 2>&1; then
|
|
28
|
+
echo "[pre-commit] gitleaks not installed — skipping secret scan." >&2
|
|
29
|
+
echo "[pre-commit] Install with: winget install gitleaks.gitleaks" >&2
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
34
|
+
CONFIG="$REPO_ROOT/.gitleaks.toml"
|
|
35
|
+
|
|
36
|
+
if [ -f "$CONFIG" ]; then
|
|
37
|
+
exec gitleaks protect --staged --no-banner --redact --config "$CONFIG"
|
|
38
|
+
else
|
|
39
|
+
exec gitleaks protect --staged --no-banner --redact
|
|
40
|
+
fi
|