rvlib-shipkit 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 +6 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +1 -0
- package/src/cli/cmd_Init.ts +2 -2
- package/src/cli/cmd_Start.ts +3 -2
- package/src/core/run-ci.ts +3 -1
- package/src/core/run-fixers.ts +14 -0
- package/src/hooks/commit-message.ts +32 -2
- package/src/hooks/run-hooks.ts +41 -5
- package/src/start-repo.ts +17 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
- **Commit gates arm themselves.** An interactive `shipkit ci` run now installs the git hooks whenever the repo has a `repo.config.ts` and `core.hooksPath` is unset or already `hooks`: hook bodies written, leak-guard keyword starter seeded, `core.hooksPath` set, idempotent. The field case: a whole drill session committed with dark gates because init was treated as "create repo.config.ts". A FOREIGN `core.hooksPath` (husky and friends) is respected and named, never taken. Hook bodies prefer the repo's own `node_modules/rvlib-shipkit` copy over the cli that happens to be running, so a repo keeps its pinned version. Scoped to the interactive gate on purpose: CI verify mode, `bump` and `release` gate runs never mutate the tree they are about to ship, and arming reports on its own line, never as a fixer rewrite.
|
|
6
|
+
- **`start` is the from-anywhere bootstrap, `init` says it is cwd-only.** His friction: "no idea what shipkit start does" and init never said where it acts. `shipkit start <name>` now also WIRES the project (runs the init scaffold) when `~/dev/<name>` already has a package.json, so a pre-existing repo goes zero to shipkit'd with one command from any cwd; an empty new dir prints the next step on stderr. stdout stays the dir alone (the cd-alias contract, frozen by test). Both usage texts state their scope and point at each other.
|
|
7
|
+
- **The commit-message gate blocks French.** Git surfaces are English, always. New `language` offense in the shared predicate: an accented letter, or two distinct French function words on one line, trips it. Quoted spans (backticks, double quotes, guillemets) are exempt as product copy under discussion. Single quotes stay in scope because they are French apostrophes. `hooks.languageAllow` in repo.config exempts listed words (proper nouns that carry accents without being a language choice), and `hooks.disable: ['commit-language']` lifts only the French check, never the dash and attribution guards. Write-time only: SK009 history audits still scan attribution and dashes, so no repo retro-fails on old French commits.
|
|
8
|
+
|
|
3
9
|
## 0.19.0
|
|
4
10
|
|
|
5
11
|
- **The attribution tell list covers session trailers and assistant share urls.** `<assistant>-Session:` trailers (claude, chatgpt, copilot, cursor, aider, devin, gemini, codex) and claude.ai/claude.com/chatgpt.com/chat.openai.com urls now trip the shared predicate (commit-msg hook + SK009 history scan). Field case: 162 session links sat in a history both gates called clean.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/cli/cmd_Ci.ts
CHANGED
package/src/cli/cmd_Init.ts
CHANGED
|
@@ -8,9 +8,9 @@ export class InitCommand extends Command {
|
|
|
8
8
|
|
|
9
9
|
static override usage = Command.Usage({
|
|
10
10
|
category: 'Setup',
|
|
11
|
-
description: '
|
|
11
|
+
description: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. Scaffolds repo.config.ts + ci/check/contracts package.json scripts, then prints how the floor resolved. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
|
|
12
12
|
examples: [
|
|
13
|
-
['
|
|
13
|
+
['wire the repo you are standing in', 'shipkit init'],
|
|
14
14
|
['in-repo-source consumer: scripts run the CLI by path', 'shipkit init --bin wiki/build/shipkit/rvlib-shipkit/src/cli.ts'],
|
|
15
15
|
],
|
|
16
16
|
})
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,10 +9,11 @@ export class StartCommand extends Command {
|
|
|
9
9
|
|
|
10
10
|
static override usage = Command.Usage({
|
|
11
11
|
category: 'Setup',
|
|
12
|
-
description: '
|
|
12
|
+
description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines, and wires shipkit on the spot (repo.config.ts via init) when the project already has a package.json. An empty new dir prints the next step instead. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
|
|
13
13
|
examples: [
|
|
14
|
-
['
|
|
14
|
+
['create or enter ~/dev/pong, wiring it if it is already a project', 'shipkit start pong'],
|
|
15
15
|
['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
|
|
16
|
+
['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
|
|
16
17
|
],
|
|
17
18
|
})
|
|
18
19
|
|
package/src/core/run-ci.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type CiOpts = {
|
|
|
27
27
|
fix?: boolean
|
|
28
28
|
/** also apply the structural SK rule fixers during the fix pass */
|
|
29
29
|
fixRules?: boolean
|
|
30
|
+
/** self-arm the git hooks in the fix pass (interactive ci entry only) */
|
|
31
|
+
armHooks?: boolean
|
|
30
32
|
/** run only the checks that can block (core/run-check onlyBlocking) */
|
|
31
33
|
onlyBlockingChecks?: boolean
|
|
32
34
|
/** task ids the caller does not need. A sub-unit release skips the tasks
|
|
@@ -76,7 +78,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
76
78
|
// mechanical failures are not information, so they never cost a round trip
|
|
77
79
|
let fixFailed = false
|
|
78
80
|
if (o.fix !== false)
|
|
79
|
-
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, print: sink })).failed
|
|
81
|
+
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
|
|
80
82
|
else sink('skip: fix pass (--no-fix)')
|
|
81
83
|
const all = allTasks(c, root)
|
|
82
84
|
const { disabled, unfilled } = all
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
|
+
import { armHooks } from '../hooks/run-hooks.ts'
|
|
14
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
15
16
|
import { runFix } from '../rules/run-fix.ts'
|
|
16
17
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
@@ -105,6 +106,10 @@ function treeState(root: string): Map<string, string> | null {
|
|
|
105
106
|
export type FixPassOpts = {
|
|
106
107
|
/** also apply the structural SK rule fixers (default false) */
|
|
107
108
|
rules?: boolean
|
|
109
|
+
/** also self-arm the git hooks (default false — ONLY the interactive
|
|
110
|
+
* `shipkit ci` entry passes true; bump and release gates must never
|
|
111
|
+
* mutate the tree they are about to ship) */
|
|
112
|
+
armHooks?: boolean
|
|
108
113
|
print: (line: string) => void
|
|
109
114
|
}
|
|
110
115
|
|
|
@@ -120,6 +125,15 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
120
125
|
// verify mode: same fixers, but rewriting anything means the COMMIT was
|
|
121
126
|
// not clean, and the gate says so instead of quietly repairing it
|
|
122
127
|
const verify = process.env.CI != null && process.env.CI !== ''
|
|
128
|
+
// commit gates arm themselves on the interactive gate: a repo.config with
|
|
129
|
+
// dark hooks is the half-wired state that let a whole drill session
|
|
130
|
+
// commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
|
|
131
|
+
// its own reported concern, never counted as a fixer rewrite.
|
|
132
|
+
if (o.armHooks === true && !verify) {
|
|
133
|
+
const armed = armHooks(root, config)
|
|
134
|
+
if (armed.state === 'armed') o.print(`ok: hooks armed (${armed.detail})`)
|
|
135
|
+
else if (armed.state === 'foreign') o.print(`skip: hooks (${armed.detail})`)
|
|
136
|
+
}
|
|
123
137
|
const { slots, unfilled, disabled } = resolveFixers(root, config)
|
|
124
138
|
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
125
139
|
for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
|
|
@@ -4,9 +4,37 @@
|
|
|
4
4
|
//
|
|
5
5
|
// mechanical text rules only, so this stays publishable.
|
|
6
6
|
|
|
7
|
-
export type OffenseKind = 'dash' | 'attribution'
|
|
7
|
+
export type OffenseKind = 'dash' | 'attribution' | 'language'
|
|
8
8
|
export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
|
|
9
9
|
|
|
10
|
+
/** git surfaces are English, always (identity/writing-voice.md). cheap French
|
|
11
|
+
* detector: an accented letter, or two distinct French function words on one
|
|
12
|
+
* line. quoted spans are exempt: they carry product copy under discussion
|
|
13
|
+
* ("Modification refusée"), not the message's own voice. single quotes stay
|
|
14
|
+
* in (they are French apostrophes: stripping l'écran would hide the French).
|
|
15
|
+
* `languageAllow` (repo.config hooks) exempts listed words, for proper nouns
|
|
16
|
+
* that carry accents without being a language choice. */
|
|
17
|
+
const QUOTED_SPANS = /`[^`\n]*`|"[^"\n]*"|“[^”\n]*”|«[^»\n]*»/g
|
|
18
|
+
const FRENCH_ACCENTS = /[àâçéèêëîïôùûœÀÂÇÉÈÊËÎÏÔÙÛŒ]/
|
|
19
|
+
const FRENCH_STOPWORDS =
|
|
20
|
+
/\b(?:le|la|les|un|une|des|du|au|aux|et|est|sont|avec|pour|dans|sur|pas|que|qui|ne|se|ce|cette|ces|chaque|tout|toute|tous|toutes|jamais|toujours|mais|donc|sans|sous|vers|chez|depuis|encore|aussi|devient|deviennent|nouvelle|ancienne)\b/gi
|
|
21
|
+
|
|
22
|
+
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
23
|
+
|
|
24
|
+
export function frenchHits(line: string, languageAllow: string[] = []): boolean {
|
|
25
|
+
let prose = line.replace(QUOTED_SPANS, ' ')
|
|
26
|
+
for (const word of languageAllow) prose = prose.replace(new RegExp(`(?:^|(?<=[^\\p{L}]))${escapeRe(word)}(?:'s)?(?=$|[^\\p{L}])`, 'giu'), ' ')
|
|
27
|
+
if (FRENCH_ACCENTS.test(prose)) return true
|
|
28
|
+
const words = new Set<string>()
|
|
29
|
+
// an ALL-CAPS token is an acronym (UN, EST, CE), not a French word
|
|
30
|
+
for (const m of prose.matchAll(FRENCH_STOPWORDS)) {
|
|
31
|
+
const w = m[0]
|
|
32
|
+
if (w.length > 1 && w === w.toUpperCase()) continue
|
|
33
|
+
words.add(w.toLowerCase())
|
|
34
|
+
}
|
|
35
|
+
return words.size >= 2
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
/** trailers and footers that name an assistant, assistant session trailers,
|
|
11
39
|
* assistant share-domain urls (a session link is a louder tell than any
|
|
12
40
|
* trailer), plus the robot emoji. the session/url family lists every
|
|
@@ -25,7 +53,7 @@ export function stripNonMessage(message: string): string {
|
|
|
25
53
|
* `#` comment lines never land in the message and are ignored; a `Revert "…"`
|
|
26
54
|
* title quotes the reverted subject verbatim and is exempt; a line-start
|
|
27
55
|
* `- ` bullet is a list marker, not punctuation. */
|
|
28
|
-
export function commitMessageOffenses(message: string): Offense[] {
|
|
56
|
+
export function commitMessageOffenses(message: string, opts?: { languageAllow?: string[] }): Offense[] {
|
|
29
57
|
const offenses: Offense[] = []
|
|
30
58
|
stripNonMessage(message).split('\n').forEach((raw, i) => {
|
|
31
59
|
if (raw.startsWith('#')) return
|
|
@@ -37,6 +65,8 @@ export function commitMessageOffenses(message: string): Offense[] {
|
|
|
37
65
|
offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
|
|
38
66
|
if (AI_ATTRIBUTION.test(raw))
|
|
39
67
|
offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
|
|
68
|
+
if (frenchHits(raw, opts?.languageAllow))
|
|
69
|
+
offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
|
|
40
70
|
})
|
|
41
71
|
return offenses
|
|
42
72
|
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// the hooks enforce. `shipkit hooks list` answers that without reading shell.
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
|
+
import { fileURLToPath } from 'url'
|
|
8
9
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
9
10
|
import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
|
|
10
11
|
import { runRootIdentity } from './root-identity.ts'
|
|
@@ -23,13 +24,46 @@ export type HooksConfig = {
|
|
|
23
24
|
/** staged-path prefixes that may never enter git (local state like
|
|
24
25
|
* '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
|
|
25
26
|
blockedPaths?: string[]
|
|
26
|
-
/**
|
|
27
|
-
|
|
27
|
+
/** words the commit-message language gate never counts as French
|
|
28
|
+
* (proper nouns that carry accents without being a language choice) */
|
|
29
|
+
languageAllow?: string[]
|
|
30
|
+
/** disable a built-in gate, visible in `hooks list`, never silent.
|
|
31
|
+
* 'commit-language' lifts ONLY the French check, keeping the dash and
|
|
32
|
+
* AI-attribution guards armed. */
|
|
33
|
+
disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
const HOOK_DIR = 'hooks'
|
|
31
37
|
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
32
38
|
|
|
39
|
+
/** how a self-written hook body should invoke shipkit in this repo: the
|
|
40
|
+
* repo's own node_modules copy when it has one (survives shim churn, pins
|
|
41
|
+
* the repo's version), else the cli that is running right now. */
|
|
42
|
+
export function hookBinInvocation(root: string): string {
|
|
43
|
+
const local = join(root, 'node_modules/rvlib-shipkit/src/cli.ts')
|
|
44
|
+
if (existsSync(local)) return 'bun node_modules/rvlib-shipkit/src/cli.ts'
|
|
45
|
+
const running = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.ts')
|
|
46
|
+
const prefix = root.endsWith('/') ? root : root + '/'
|
|
47
|
+
return running.startsWith(prefix) ? `bun ${running.slice(prefix.length)}` : `bun ${running}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'; detail: string }
|
|
51
|
+
|
|
52
|
+
/** self-arming: a repo.config with dark commit gates is the half-wired state
|
|
53
|
+
* that let a whole drill session commit ungated (2026-08-04). Idempotent,
|
|
54
|
+
* and a FOREIGN core.hooksPath is respected and named, never taken. */
|
|
55
|
+
export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
56
|
+
const inRepo = Bun.spawnSync(['git', 'rev-parse', '--is-inside-work-tree'], { cwd: root })
|
|
57
|
+
if (inRepo.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
|
|
58
|
+
const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
|
|
59
|
+
const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
60
|
+
if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
|
|
61
|
+
const r = installHooks(root, hookBinInvocation(root), config.hooks?.keywordFile)
|
|
62
|
+
if (r.note.startsWith('WARN')) return { state: 'foreign', detail: r.note }
|
|
63
|
+
if (r.written.length === 0 && current === HOOK_DIR) return { state: 'current', detail: 'hooks already armed' }
|
|
64
|
+
return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
/** wire `core.hooksPath` and write the two hook bodies. Idempotent.
|
|
34
68
|
* `keywordFile` = the repo's configured leak-guard file, seeding target. */
|
|
35
69
|
export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
|
|
@@ -149,9 +183,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
149
183
|
const out: GateOutcome[] = []
|
|
150
184
|
const message = readFileSync(messageFile, 'utf8')
|
|
151
185
|
if (!(h.disable ?? []).includes('commit-message')) {
|
|
152
|
-
const
|
|
186
|
+
const languageOff = (h.disable ?? []).includes('commit-language')
|
|
187
|
+
const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
|
|
188
|
+
.filter((o) => o.kind !== 'language' || !languageOff)
|
|
153
189
|
out.push(offenses.length === 0
|
|
154
|
-
? { id: 'commit-message', ok: true, lines: [
|
|
190
|
+
? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation, no AI attribution${languageOff ? '' : ', English'})`] }
|
|
155
191
|
: {
|
|
156
192
|
id: 'commit-message',
|
|
157
193
|
ok: false,
|
|
@@ -192,7 +228,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
192
228
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
193
229
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
194
230
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
195
|
-
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution`,
|
|
231
|
+
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
|
|
196
232
|
`commit-msg leak-guard${leakState} — the message text`,
|
|
197
233
|
...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
|
|
198
234
|
]
|
package/src/start-repo.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
8
8
|
import { homedir } from 'os'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
+
import { initRepo } from './init-repo.ts'
|
|
10
11
|
|
|
11
12
|
export type StartOpts = {
|
|
12
13
|
/** parent dir for repos — default ~/dev */
|
|
@@ -19,7 +20,7 @@ export type StartOpts = {
|
|
|
19
20
|
skip?: string[]
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function startRepo(name: string, opts: StartOpts = {}): number {
|
|
23
|
+
export async function startRepo(name: string, opts: StartOpts = {}): Promise<number> {
|
|
23
24
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
|
|
24
25
|
console.error(`FAIL: bad repo name '${name}' — letters/digits/._- only, no leading dot, no slashes`)
|
|
25
26
|
return 2
|
|
@@ -55,6 +56,21 @@ export function startRepo(name: string, opts: StartOpts = {}): number {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
}
|
|
59
|
+
// start is the from-anywhere verb: a dir that is already a real project
|
|
60
|
+
// (package.json) gets wired on the spot, so a pre-existing repo goes
|
|
61
|
+
// 0 → shipkit'd with the one command. init itself stays cwd-only, like
|
|
62
|
+
// git init. A brand-new empty dir cannot be wired yet, say what is next.
|
|
63
|
+
if (!existsSync(join(dir, 'repo.config.ts'))) {
|
|
64
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
65
|
+
// initRepo reports on stdout, but start's stdout contract is THE
|
|
66
|
+
// DIR ONLY (a sourced alias cd's to it) — reroute to stderr here
|
|
67
|
+
const realLog = console.log
|
|
68
|
+
console.log = ((...a: unknown[]) => console.error(...a)) as typeof console.log
|
|
69
|
+
try { await initRepo(dir, {}) } finally { console.log = realLog }
|
|
70
|
+
} else {
|
|
71
|
+
console.error(`next: scaffold the app in ${dir} (package.json), then \`shipkit init\` inside it — the commit gates arm on the first ci run`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
58
74
|
console.log(dir)
|
|
59
75
|
return 0
|
|
60
76
|
}
|