tspl-cli 0.0.0-stage → 0.1.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 +38 -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,39 @@
|
|
|
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
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node 22 or newer
|
|
8
|
+
- git
|
|
9
|
+
- Access to a TSPL API server (ask your team for its address)
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g tspl-cli
|
|
15
|
+
tspl --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Get started
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
tspl setup --api https://your-tspl-api.example.com # log in and check your machine
|
|
22
|
+
cd path/to/your/project
|
|
23
|
+
tspl init # link this folder to a TSPL project
|
|
24
|
+
tspl connect # add tspl to your AI tool (MCP)
|
|
25
|
+
tspl task new # describe a task
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`tspl setup` with no `--api` uses `http://localhost:1500`, for a TSPL API running on your own machine.
|
|
29
|
+
|
|
30
|
+
## Where tspl keeps data
|
|
31
|
+
|
|
32
|
+
- `~/.tspl`: your login, settings and the local code map of each repo. The map is built from git and never uploaded.
|
|
33
|
+
- `.tspl/project.json` in your project: the project's checks and settings, meant to be committed.
|
|
34
|
+
|
|
35
|
+
## Update
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g tspl-cli@latest
|
|
39
|
+
```
|
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.0",
|
|
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
|
+
}
|