tspl-cli 0.0.0-stage → 0.1.1
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 +127 -2
- package/bin/tspl.js +10 -0
- package/package.json +41 -4
- package/src/active-org.js +49 -0
- package/src/analysis/analyzer.js +61 -0
- package/src/analysis/extract.js +37 -0
- package/src/analysis/file-filter.js +69 -0
- package/src/analysis/git-reader.js +108 -0
- package/src/analysis/html-parser.js +46 -0
- package/src/analysis/js-parser.js +473 -0
- package/src/analysis/py-parser.js +66 -0
- package/src/analysis/repo-index.js +107 -0
- package/src/api-client.js +108 -0
- package/src/browser-tests.js +209 -0
- package/src/commands/analyze.command.js +81 -0
- package/src/commands/auth.command.js +250 -0
- package/src/commands/build.command.js +267 -0
- package/src/commands/connect.command.js +270 -0
- package/src/commands/engines.command.js +136 -0
- package/src/commands/find.command.js +107 -0
- package/src/commands/init.command.js +79 -0
- package/src/commands/link.command.js +65 -0
- package/src/commands/mcp.command.js +12 -0
- package/src/commands/member.command.js +82 -0
- package/src/commands/plan.command.js +161 -0
- package/src/commands/project.command.js +191 -0
- package/src/commands/setup.command.js +63 -0
- package/src/commands/sync.command.js +59 -0
- package/src/commands/task.command.js +475 -0
- package/src/commands/usage.command.js +105 -0
- package/src/config-store.js +255 -0
- package/src/connect/claude-skill.md +30 -0
- package/src/engines.js +218 -0
- package/src/find/graph.js +272 -0
- package/src/find/search.js +473 -0
- package/src/find/text.js +86 -0
- package/src/git-helper.js +102 -0
- package/src/index.js +91 -0
- package/src/mcp/server.js +708 -0
- package/src/project-config.js +197 -0
- package/src/project-init.js +447 -0
- package/src/providers/github.js +249 -0
- package/src/providers/index.js +83 -0
- package/src/runtime-check.js +18 -0
- package/src/setup.js +180 -0
- package/src/shared/approaches.js +44 -0
- package/src/shared/constants.js +89 -0
- package/src/shared/index.js +8 -0
- package/src/shared/workflow-progress.js +67 -0
- package/src/shared/workflow.js +381 -0
- package/src/stack-detector.js +47 -0
- package/src/task/approaches.js +53 -0
- package/src/task/branch-choice.js +69 -0
- package/src/task/brief.js +115 -0
- package/src/task/build.js +549 -0
- package/src/task/check.js +415 -0
- package/src/task/conventions.js +113 -0
- package/src/task/gate.js +88 -0
- package/src/task/hooks.js +82 -0
- package/src/task/plan-draft.js +43 -0
- package/src/task/plan-page.js +123 -0
- package/src/task/plan.js +293 -0
- package/src/task/planning-context.js +165 -0
- package/src/task/refine.js +73 -0
- package/src/workflow/agents.js +336 -0
- package/src/workflow/checkout-lock.js +92 -0
- package/src/workflow/client.js +82 -0
- package/src/workflow/controller.js +585 -0
- package/src/workflow/evidence.js +276 -0
- package/src/workflow/pr-body.js +52 -0
- package/src/workflow/process.js +82 -0
- package/src/workflow/review.js +128 -0
- package/src/workflow/runtime.js +148 -0
- package/src/workflow/wait.js +58 -0
- package/src/workspace.js +250 -0
package/README.md
CHANGED
|
@@ -1,3 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tspl
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`tspl` is the command-line tool for the TSPL AI feature-development tool. It reads your repositories (no AI, nothing leaves your machine), turns a task into a clear brief, plans the change with you, enforces the approved plan while an AI tool builds it, runs the tests and opens the pull request.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
npm i -g tspl-cli → tspl setup → tspl init → tspl task new "…" → tspl t-xxxx → tspl plan approve t-xxxx → tspl task run t-xxxx
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Node 22 or newer
|
|
12
|
+
- git
|
|
13
|
+
- A TSPL account and the address of your TSPL API (ask your team)
|
|
14
|
+
- An AI tool such as Claude Code, Codex, Cursor or Gemini, using your own subscription
|
|
15
|
+
|
|
16
|
+
## 1. Install (once per computer)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g tspl-cli
|
|
20
|
+
tspl --version
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Update later with `npm install -g tspl-cli@latest`.
|
|
24
|
+
|
|
25
|
+
## 2. Configure (once per computer)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
tspl setup --api https://<your-tspl-api> --connect claude
|
|
29
|
+
tspl whoami
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`tspl setup` walks you through:
|
|
33
|
+
|
|
34
|
+
- logging in to the TSPL API with your email and password
|
|
35
|
+
- choosing which AI engine does each step
|
|
36
|
+
- connecting your AI tool: the `/tspl` command, the edit lock and the MCP plug-in
|
|
37
|
+
|
|
38
|
+
Connect other tools with `--connect codex cursor gemini antigravity vscode`, or later with `tspl connect <tool>`. Restart your AI tool after connecting it.
|
|
39
|
+
|
|
40
|
+
Several organizations:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
tspl login --org <slug> # log in to a specific organization
|
|
44
|
+
tspl org use <slug> # switch to one you are already logged in to
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 3. Set up a project (once per project folder)
|
|
48
|
+
|
|
49
|
+
Run inside the project folder:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
cd path/to/your/project
|
|
53
|
+
tspl init
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This links the folder to a TSPL project, finds its test and lint checks, and saves `.tspl/project.json`. Commit that file so the whole team shares the same rules. tspl then reads the code into a local map (no AI, nothing uploaded).
|
|
57
|
+
|
|
58
|
+
Running `tspl` with no arguments sets the folder up if needed, or shows its open tasks.
|
|
59
|
+
|
|
60
|
+
## 4. Work on a task
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
tspl task new "Add a Contact page at /contact" # answer a few questions; prints the task id, e.g. t-xxxx
|
|
64
|
+
tspl t-xxxx # compare approaches, pick one, write the plan
|
|
65
|
+
tspl plan show t-xxxx # files to edit, create and remove, and tests
|
|
66
|
+
tspl plan approve t-xxxx # developer approves, then the PM approves
|
|
67
|
+
tspl task run t-xxxx # code, run checks, repair, review, draft pull requests, CI
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Other ways to start a task:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
tspl task new --ticket TEST-1 "Add About page" # with a ticket id
|
|
74
|
+
tspl task new --file ticket.txt # ticket text from a file
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`tspl task run` asks which branch each repo starts from and the name of the new branch. Skip the questions with:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
tspl task run t-xxxx --base main --branch feature/contact-page
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Run `tspl task run t-xxxx` again to resume after a stop. To retry one stage, add `--stage testing` or `--stage reviewing`.
|
|
84
|
+
|
|
85
|
+
From your AI tool, type `/tspl <what should change>` to do the same steps there.
|
|
86
|
+
|
|
87
|
+
## Everyday commands
|
|
88
|
+
|
|
89
|
+
| What | Command |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| Where a task is and the next command | `tspl task status t-xxxx` |
|
|
92
|
+
| Tasks on this computer | `tspl task list` |
|
|
93
|
+
| Show a task brief | `tspl task show t-xxxx` |
|
|
94
|
+
| Change a task | `tspl task edit t-xxxx` |
|
|
95
|
+
| Plans waiting for a decision | `tspl plan list` |
|
|
96
|
+
| Send a plan back | `tspl plan reject t-xxxx --note "..."` |
|
|
97
|
+
| PM confirms scope / sends it back | `tspl task scope t-xxxx` / `tspl task scope t-xxxx --back --note "..."` |
|
|
98
|
+
| Keep going through approvals and CI | `tspl task watch t-xxxx` |
|
|
99
|
+
| Builds in progress | `tspl build status` |
|
|
100
|
+
| Stop a build and lift its edit lock | `tspl build stop t-xxxx` |
|
|
101
|
+
| Allow a new plan after a stop | `tspl plan amend t-xxxx` |
|
|
102
|
+
| Delete a task | `tspl task delete t-xxxx --reason "..."` |
|
|
103
|
+
| Who is logged in | `tspl whoami` |
|
|
104
|
+
| AI engine per step | `tspl engines` |
|
|
105
|
+
|
|
106
|
+
## Team and projects
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
tspl project list # projects you can see
|
|
110
|
+
tspl member list # people and their roles
|
|
111
|
+
tspl member add <email> # owner or admin
|
|
112
|
+
tspl member role <email> <role>
|
|
113
|
+
tspl project add-member <project> <email>
|
|
114
|
+
tspl project remove-member <project> <email>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Where tspl keeps data
|
|
118
|
+
|
|
119
|
+
- `~/.tspl`: your login, settings and the local code map of each repo. The map is built from git and never uploaded.
|
|
120
|
+
- `.tspl/project.json` in your project: the project's checks and settings, meant to be committed.
|
|
121
|
+
- The TSPL server stores tasks, plans, approvals, reports and AI usage, never your code.
|
|
122
|
+
|
|
123
|
+
## Help
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
tspl --help
|
|
127
|
+
tspl <command> --help
|
|
128
|
+
```
|
package/bin/tspl.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tspl-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "TSPL command-line tool: learns your code, plans changes with you, builds, tests and opens pull requests",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tspl",
|
|
7
|
+
"cli",
|
|
8
|
+
"ai",
|
|
9
|
+
"mcp",
|
|
10
|
+
"claude",
|
|
11
|
+
"code-review",
|
|
12
|
+
"planning"
|
|
13
|
+
],
|
|
14
|
+
"license": "UNLICENSED",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/meet2amit/tspl-pm-aitool.git",
|
|
18
|
+
"directory": "apps/cli"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"tspl": "bin/tspl.js"
|
|
22
|
+
},
|
|
23
|
+
"main": "src/index.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@babel/parser": "^7.29.9",
|
|
34
|
+
"@inquirer/prompts": "^8.7.2",
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
36
|
+
"commander": "^15.0.0",
|
|
37
|
+
"zod": "^4.6.5"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Fs = require('fs')
|
|
4
|
+
const Path = require('path')
|
|
5
|
+
|
|
6
|
+
const { readLinks, accountFor, setActiveOrganization } = require('./config-store')
|
|
7
|
+
const { findProjectConfig } = require('./project-config')
|
|
8
|
+
|
|
9
|
+
const inside = (dir, parent) => Boolean(parent) && (dir === parent || dir.startsWith(`${parent}${Path.sep}`))
|
|
10
|
+
|
|
11
|
+
const real = dir => {
|
|
12
|
+
try {
|
|
13
|
+
return Fs.realpathSync(dir)
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return Path.resolve(dir)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The organization a folder belongs to: the one named in its .tspl/project.json, else the one whose linked project
|
|
21
|
+
* contains the folder. null when the folder is not part of a project or the link is ambiguous.
|
|
22
|
+
*/
|
|
23
|
+
const organizationOfFolder = cwd => {
|
|
24
|
+
try {
|
|
25
|
+
const found = findProjectConfig(cwd)
|
|
26
|
+
if (found) return found.config.organization
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// a broken project file is reported by the command that needs it
|
|
29
|
+
}
|
|
30
|
+
const dir = real(cwd)
|
|
31
|
+
const orgs = new Set(
|
|
32
|
+
Object.entries(readLinks())
|
|
33
|
+
.filter(([, link]) => inside(dir, link.folder && real(link.folder)) || Object.values(link.repos || {}).some(repo => inside(dir, repo.path && real(repo.path))))
|
|
34
|
+
.map(([key]) => key.split('/')[0]),
|
|
35
|
+
)
|
|
36
|
+
return orgs.size === 1 ? [...orgs][0] : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Makes every command in this folder use the login of the folder's organization (when this machine has one). */
|
|
40
|
+
const useFolderOrganization = cwd => {
|
|
41
|
+
const organization = organizationOfFolder(cwd)
|
|
42
|
+
if (organization && accountFor(organization)) setActiveOrganization(organization)
|
|
43
|
+
return organization
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
organizationOfFolder,
|
|
48
|
+
useFolderOrganization,
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { readIndex, writeIndex } = require('../config-store')
|
|
4
|
+
const { buildRepoIndex } = require('./repo-index')
|
|
5
|
+
|
|
6
|
+
const COUNTED = ['routes', 'models', 'uiRoutes', 'screens', 'components', 'httpCalls']
|
|
7
|
+
|
|
8
|
+
const refreshIndex = async (project, repos, onRepo = () => {}, saved = readIndex(project._id)) => {
|
|
9
|
+
const index = { projectId: project._id, repos: { ...(saved.repos || {}) } }
|
|
10
|
+
let changed = false
|
|
11
|
+
for (const repo of repos) {
|
|
12
|
+
try {
|
|
13
|
+
const repoIndex = await buildRepoIndex(repo, index.repos[repo._id])
|
|
14
|
+
const isNew = Boolean(repoIndex.newlyParsed || !index.repos[repo._id] || index.repos[repo._id].commitSha !== repoIndex.commitSha)
|
|
15
|
+
changed = changed || isNew
|
|
16
|
+
index.repos[repo._id] = repoIndex
|
|
17
|
+
onRepo(repo, repoIndex, null, isNew)
|
|
18
|
+
} catch (err) {
|
|
19
|
+
onRepo(repo, null, err)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const linked = new Set(repos.map(repo => repo._id))
|
|
23
|
+
Object.keys(index.repos)
|
|
24
|
+
.filter(id => !linked.has(id))
|
|
25
|
+
.forEach(id => {
|
|
26
|
+
delete index.repos[id]
|
|
27
|
+
changed = true
|
|
28
|
+
})
|
|
29
|
+
if (changed) {
|
|
30
|
+
writeIndex(project._id, index)
|
|
31
|
+
}
|
|
32
|
+
return index
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const describeRepo = repoIndex => {
|
|
36
|
+
const stats = { byStatus: {} }
|
|
37
|
+
COUNTED.forEach(key => { stats[key] = 0 })
|
|
38
|
+
repoIndex.files.forEach(file => {
|
|
39
|
+
stats.byStatus[file.status] = (stats.byStatus[file.status] || 0) + 1
|
|
40
|
+
})
|
|
41
|
+
Object.values(repoIndex.facts).forEach(facts => {
|
|
42
|
+
if (!facts) return
|
|
43
|
+
COUNTED.forEach(key => {
|
|
44
|
+
if (Array.isArray(facts[key])) stats[key] += facts[key].length
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
const codeFiles = Object.keys(repoIndex.facts).length
|
|
48
|
+
return {
|
|
49
|
+
branch: repoIndex.branch,
|
|
50
|
+
commitSha: repoIndex.commitSha,
|
|
51
|
+
date: repoIndex.date,
|
|
52
|
+
codeFiles,
|
|
53
|
+
newlyAnalyzed: repoIndex.newlyParsed || 0,
|
|
54
|
+
stats,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = {
|
|
59
|
+
refreshIndex,
|
|
60
|
+
describeRepo,
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path')
|
|
4
|
+
const { extractJs } = require('./js-parser')
|
|
5
|
+
const { extractPy } = require('./py-parser')
|
|
6
|
+
const { extractHtml } = require('./html-parser')
|
|
7
|
+
|
|
8
|
+
const PARSER_VERSION = 3
|
|
9
|
+
|
|
10
|
+
const LANGUAGES = {
|
|
11
|
+
'.js': 'javascript',
|
|
12
|
+
'.jsx': 'javascript',
|
|
13
|
+
'.mjs': 'javascript',
|
|
14
|
+
'.cjs': 'javascript',
|
|
15
|
+
'.ts': 'typescript',
|
|
16
|
+
'.tsx': 'typescript',
|
|
17
|
+
'.py': 'python',
|
|
18
|
+
'.html': 'html',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const withoutEmpty = facts =>
|
|
22
|
+
Object.fromEntries(
|
|
23
|
+
Object.entries(facts).filter(([, value]) => !(Array.isArray(value) && !value.length)),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const extractFacts = (path, code) => {
|
|
27
|
+
const ext = Path.extname(path).toLowerCase()
|
|
28
|
+
const language = LANGUAGES[ext]
|
|
29
|
+
const parsers = { python: extractPy, html: extractHtml }
|
|
30
|
+
const facts = parsers[language] ? parsers[language](code) : extractJs(code, ext)
|
|
31
|
+
return withoutEmpty({ language, lines: code.split('\n').length, ...facts })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
PARSER_VERSION,
|
|
36
|
+
extractFacts,
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const Path = require('path')
|
|
4
|
+
|
|
5
|
+
const MAX_BYTES = 512 * 1024
|
|
6
|
+
|
|
7
|
+
const CODE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.py', '.html'])
|
|
8
|
+
|
|
9
|
+
const SECRET_PATTERNS = [
|
|
10
|
+
/(^|\/)\.env(\.[^/]*)?$/i,
|
|
11
|
+
/\.(pem|key|p12|pfx|jks|keystore|p8|mobileprovision|cer|crt|der|asc|gpg)$/i,
|
|
12
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
|
|
13
|
+
/(^|\/)\.(npmrc|pypirc|netrc)$/i,
|
|
14
|
+
/(^|\/)google-services\.json$/i,
|
|
15
|
+
/(^|\/)GoogleService-Info\.plist$/i,
|
|
16
|
+
/(^|\/)service-account[^/]*\.json$/i,
|
|
17
|
+
/(^|\/)\.(aws|ssh)\//i,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const SECRET_NAME = /(^|[._-])(secrets?|credentials?)([._-]|$)/i
|
|
21
|
+
|
|
22
|
+
const GENERATED_PATTERNS = [
|
|
23
|
+
/(^|\/)(node_modules|bower_components|dist|build|coverage|vendor|\.next|\.expo|\.nuxt|Pods|\.gradle)\//,
|
|
24
|
+
/(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|npm-shrinkwrap\.json|Podfile\.lock)$/,
|
|
25
|
+
/\.(min\.js|min\.css|map|snap|lock)$/i,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const BINARY_EXTENSIONS = new Set([
|
|
29
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tif', '.tiff', '.heic',
|
|
30
|
+
'.mp4', '.mov', '.avi', '.mp3', '.wav', '.ogg', '.m4a',
|
|
31
|
+
'.pdf', '.zip', '.gz', '.tgz', '.rar', '.7z', '.jar', '.war', '.tar',
|
|
32
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
33
|
+
'.psd', '.ai', '.sketch', '.fig', '.xls', '.xlsx', '.doc', '.docx', '.ppt', '.pptx',
|
|
34
|
+
'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite', '.apk', '.ipa', '.aab',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const SECRET_VALUE_NAME = /(key|secret|token|passw|pwd|auth|credential|private|salt|signature|cert|bearer|session|cookie|dsn)/i
|
|
38
|
+
const SECRET_VALUE = /-----BEGIN|^(sk|pk|rk|ghp|gho|xox[abp])[-_]|^AKIA|^eyJ|:\/\/[^\s/]*:[^\s/]*@|(password|pwd|secret|token)=/i
|
|
39
|
+
|
|
40
|
+
const isSecretValue = (name, value) => {
|
|
41
|
+
const text = String(value)
|
|
42
|
+
if ((name && SECRET_VALUE_NAME.test(name)) || SECRET_VALUE.test(text)) return true
|
|
43
|
+
return !/\s/.test(text) && text.length >= 20 && /[a-z]/i.test(text) && /\d/.test(text)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const classify = (path, size) => {
|
|
47
|
+
if (
|
|
48
|
+
SECRET_PATTERNS.some(pattern => pattern.test(path)) ||
|
|
49
|
+
SECRET_NAME.test(Path.basename(path))
|
|
50
|
+
) {
|
|
51
|
+
return 'skipped-secret'
|
|
52
|
+
}
|
|
53
|
+
if (GENERATED_PATTERNS.some(pattern => pattern.test(path))) {
|
|
54
|
+
return 'skipped-generated'
|
|
55
|
+
}
|
|
56
|
+
const ext = Path.extname(path).toLowerCase()
|
|
57
|
+
if (BINARY_EXTENSIONS.has(ext)) {
|
|
58
|
+
return 'skipped-binary'
|
|
59
|
+
}
|
|
60
|
+
if (size > MAX_BYTES) {
|
|
61
|
+
return 'skipped-large'
|
|
62
|
+
}
|
|
63
|
+
return CODE_EXTENSIONS.has(ext) ? 'parsed' : 'listed'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
classify,
|
|
68
|
+
isSecretValue,
|
|
69
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execFileSync, spawn } = require('child_process')
|
|
4
|
+
|
|
5
|
+
const git = (dir, args, options = {}) =>
|
|
6
|
+
execFileSync('git', ['-C', dir, ...args], {
|
|
7
|
+
encoding: options.encoding || 'utf8',
|
|
8
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
9
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const verify = (dir, ref) => {
|
|
13
|
+
try {
|
|
14
|
+
return git(dir, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).trim()
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const resolveRef = (dir, branch) => {
|
|
21
|
+
const candidates = branch
|
|
22
|
+
? [`refs/remotes/origin/${branch}`, `refs/heads/${branch}`]
|
|
23
|
+
: ['HEAD']
|
|
24
|
+
for (const ref of candidates) {
|
|
25
|
+
const commitSha = verify(dir, ref)
|
|
26
|
+
if (commitSha) {
|
|
27
|
+
const date = git(dir, ['log', '-1', '--format=%cs', commitSha]).trim()
|
|
28
|
+
return { ref, commitSha, date }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw new Error(branch ? `branch "${branch}" not found` : 'no commits found')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const listFiles = (dir, ref) =>
|
|
35
|
+
git(dir, ['ls-tree', '-r', '-l', '-z', ref])
|
|
36
|
+
.split('\0')
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map(line => {
|
|
39
|
+
const tab = line.indexOf('\t')
|
|
40
|
+
const [, type, blobHash, size] = line.slice(0, tab).split(/\s+/)
|
|
41
|
+
return { type, blobHash, size: Number(size) || 0, path: line.slice(tab + 1) }
|
|
42
|
+
})
|
|
43
|
+
.filter(entry => entry.type === 'blob')
|
|
44
|
+
.map(({ blobHash, size, path }) => ({ blobHash, size, path }))
|
|
45
|
+
|
|
46
|
+
const HISTORY_COMMITS = 4000
|
|
47
|
+
const HISTORY_PER_FILE = 40
|
|
48
|
+
const HISTORY_MAX_FILES = 30
|
|
49
|
+
|
|
50
|
+
const fileHistory = (dir, ref, wanted) => {
|
|
51
|
+
const history = {}
|
|
52
|
+
const log = git(dir, ['-c', 'core.quotePath=false', 'log', ref, '--no-merges', '-n', String(HISTORY_COMMITS), '--name-only', '--format=%x00%s'])
|
|
53
|
+
log
|
|
54
|
+
.split('\0')
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.forEach(block => {
|
|
57
|
+
const [message, ...paths] = block.split('\n').map(line => line.trim())
|
|
58
|
+
const files = paths.filter(path => path && wanted.has(path))
|
|
59
|
+
if (!message || /^(merge|squashed commit)/i.test(message) || paths.filter(Boolean).length > HISTORY_MAX_FILES) return
|
|
60
|
+
files.forEach(path => {
|
|
61
|
+
const messages = history[path] || (history[path] = [])
|
|
62
|
+
if (messages.length < HISTORY_PER_FILE && !messages.includes(message)) messages.push(message)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
return history
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const readBlobs = (dir, hashes) =>
|
|
69
|
+
new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn('git', ['-C', dir, 'cat-file', '--batch'], {
|
|
71
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
72
|
+
})
|
|
73
|
+
const chunks = []
|
|
74
|
+
child.stdout.on('data', chunk => chunks.push(chunk))
|
|
75
|
+
child.on('error', reject)
|
|
76
|
+
child.on('close', code => {
|
|
77
|
+
if (code !== 0) {
|
|
78
|
+
reject(new Error(`git cat-file failed in ${dir}`))
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
const buffer = Buffer.concat(chunks)
|
|
82
|
+
const blobs = new Map()
|
|
83
|
+
let offset = 0
|
|
84
|
+
while (offset < buffer.length) {
|
|
85
|
+
const newline = buffer.indexOf(0x0a, offset)
|
|
86
|
+
if (newline === -1) {
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
const [hash, type, size] = buffer.toString('utf8', offset, newline).split(' ')
|
|
90
|
+
offset = newline + 1
|
|
91
|
+
if (type === 'missing') {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const length = Number(size)
|
|
95
|
+
blobs.set(hash, buffer.toString('utf8', offset, offset + length))
|
|
96
|
+
offset += length + 1
|
|
97
|
+
}
|
|
98
|
+
resolve(blobs)
|
|
99
|
+
})
|
|
100
|
+
child.stdin.end(`${hashes.join('\n')}\n`)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
resolveRef,
|
|
105
|
+
listFiles,
|
|
106
|
+
fileHistory,
|
|
107
|
+
readBlobs,
|
|
108
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const LIMIT = 500
|
|
4
|
+
const LABEL_LIMIT = 150
|
|
5
|
+
|
|
6
|
+
const CUSTOM_TAG = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g
|
|
7
|
+
const LABEL_ATTRIBUTE = /\s(?:title|placeholder|label|alt|aria-label|matTooltip|ngbTooltip)\s*=\s*"([^"{}]+)"/g
|
|
8
|
+
const ROUTER_LINK = /\srouterLink\s*=\s*"(\/[^"{}]*)"/g
|
|
9
|
+
|
|
10
|
+
const labelText = value => {
|
|
11
|
+
const label = value.replace(/ /g, ' ').replace(/\s+/g, ' ').trim()
|
|
12
|
+
return label.length >= 2 && label.length <= 80 && /[a-z]/i.test(label) ? label : null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const extractHtml = code => {
|
|
16
|
+
const tags = new Set()
|
|
17
|
+
const labels = new Set()
|
|
18
|
+
const links = new Set()
|
|
19
|
+
const markup = code
|
|
20
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
21
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
|
|
22
|
+
|
|
23
|
+
for (const match of markup.matchAll(CUSTOM_TAG)) tags.add(match[1])
|
|
24
|
+
for (const match of markup.matchAll(ROUTER_LINK)) links.add(match[1])
|
|
25
|
+
for (const match of markup.matchAll(LABEL_ATTRIBUTE)) {
|
|
26
|
+
const label = labelText(match[1])
|
|
27
|
+
if (label) labels.add(label)
|
|
28
|
+
}
|
|
29
|
+
markup
|
|
30
|
+
.replace(/\{\{[\s\S]*?\}\}/g, ' ')
|
|
31
|
+
.split(/<[^>]*>/)
|
|
32
|
+
.forEach(chunk => {
|
|
33
|
+
const label = labelText(chunk)
|
|
34
|
+
if (label) labels.add(label)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
tags: [...tags].slice(0, LIMIT),
|
|
39
|
+
links: [...links].slice(0, LIMIT),
|
|
40
|
+
labels: [...labels].slice(0, LABEL_LIMIT),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
extractHtml,
|
|
46
|
+
}
|