@plainconceptsplatform/agent-harness 3.8.3 → 3.9.0-alpha.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/LICENSE +21 -21
- package/README.md +551 -551
- package/cli/commands/join.js +259 -259
- package/cli/commands/migrate.js +310 -310
- package/cli/commands/shared.js +27 -27
- package/cli/commands/single.js +79 -79
- package/cli/commands/update.js +121 -121
- package/cli/commands/wizard.js +134 -134
- package/cli/fragments/archive/az.md +97 -97
- package/cli/fragments/archive/gh.md +96 -96
- package/cli/fragments/archive/gl.md +96 -96
- package/cli/fragments/archive/none.md +75 -75
- package/cli/fragments/guardrails/codegraph.md +5 -5
- package/cli/fragments/guardrails/humanizer.md +4 -4
- package/cli/fragments/guardrails/memory.md +4 -4
- package/cli/fragments/guardrails/rtk.md +3 -3
- package/cli/fragments/guardrails/simple-english.md +4 -4
- package/cli/fragments/ops-backlog/az.md +28 -28
- package/cli/fragments/ops-backlog/gh.md +28 -28
- package/cli/fragments/ops-backlog/jira.md +28 -28
- package/cli/fragments/ops-review/az.md +62 -62
- package/cli/fragments/ops-review/gh.md +51 -51
- package/cli/fragments/ops-review/gl.md +55 -55
- package/cli/fragments/ops-ship/az.md +81 -81
- package/cli/fragments/ops-ship/gh.md +67 -67
- package/cli/fragments/ops-ship/gl.md +84 -84
- package/cli/index.js +116 -116
- package/cli/presets/agents-content.json +34 -34
- package/cli/presets/browser.json +10 -10
- package/cli/presets/clean.json +21 -21
- package/cli/presets/models.json +68 -68
- package/cli/presets/optimization.json +32 -32
- package/cli/presets/platforms.json +76 -76
- package/cli/presets/source.json +23 -23
- package/cli/steps/browser/index.js +52 -48
- package/cli/steps/clean/index.js +120 -120
- package/cli/steps/copy/agents.js +176 -176
- package/cli/steps/copy/commands.js +79 -79
- package/cli/steps/copy/fullstack-engineer.js +81 -89
- package/cli/steps/copy/index.js +88 -88
- package/cli/steps/copy/opencode-json.js +324 -324
- package/cli/steps/copy/skills.js +316 -316
- package/cli/steps/metadata/index.js +115 -115
- package/cli/steps/models/format.js +88 -88
- package/cli/steps/models/index.js +64 -64
- package/cli/steps/models/write.js +38 -38
- package/cli/steps/openspec/index.js +136 -136
- package/cli/steps/optimization/codegraph.js +141 -141
- package/cli/steps/optimization/detect.js +66 -66
- package/cli/steps/optimization/humanizer.js +17 -17
- package/cli/steps/optimization/index.js +158 -158
- package/cli/steps/optimization/memory.js +91 -91
- package/cli/steps/optimization/patch-guardrails.js +111 -111
- package/cli/steps/optimization/simple-english.js +17 -17
- package/cli/steps/optimization/skills-lock.js +30 -30
- package/cli/steps/platform/index.js +109 -109
- package/cli/steps/source/index.js +123 -123
- package/cli/utils/agent-color.js +111 -111
- package/cli/utils/copy.js +134 -134
- package/cli/utils/exec-spinner.js +47 -47
- package/cli/utils/exec.js +134 -134
- package/cli/utils/legacy-check.js +30 -30
- package/cli/utils/models-cache.js +58 -58
- package/cli/utils/models-pricing.js +42 -42
- package/cli/utils/paths.js +82 -82
- package/cli/utils/process.js +3 -3
- package/cli/utils/terminal.js +6 -6
- package/cli/utils/update-manifest.js +75 -75
- package/harness/.opencode/_gitignore +12 -10
- package/harness/.opencode/commands/init.md +5 -5
- package/harness/.opencode/commands/make-architecture.md +5 -5
- package/harness/.opencode/commands/make-design.md +5 -5
- package/harness/.opencode/commands/make-engineer.md +5 -5
- package/harness/.opencode/commands/make-guardrails.md +5 -5
- package/harness/.opencode/commands/make-user-model.md +5 -5
- package/harness/.opencode/commands/ops-backlog.md +10 -10
- package/harness/.opencode/commands/ops-review.md +10 -10
- package/harness/.opencode/commands/ops-ship.md +9 -9
- package/harness/.opencode/commands/plan-apply.md +9 -9
- package/harness/.opencode/commands/plan-archive.md +7 -7
- package/harness/.opencode/commands/plan-explore.md +11 -11
- package/harness/.opencode/commands/plan-goal.md +5 -5
- package/harness/.opencode/commands/plan-propose.md +11 -11
- package/harness/.opencode/commands/plan-quick.md +7 -7
- package/harness/.opencode/commands/plan-story.md +11 -11
- package/harness/.opencode/commands/repo-audit.md +7 -7
- package/harness/.opencode/commands/repo-help.md +5 -5
- package/harness/.opencode/commands/repo-initialize.md +5 -5
- package/harness/.opencode/commands/repo-onboard.md +5 -5
- package/harness/.opencode/commands/repo-verify.md +5 -5
- package/harness/.opencode/package.json +6 -5
- package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
- package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
- package/harness/.opencode/plugins/pc-subagent-tiers-core.js +337 -0
- package/harness/.opencode/plugins/pc-subagent-tiers.js +56 -367
- package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
- package/harness/.opencode/scripts/check-diff.mjs +305 -305
- package/harness/.opencode/scripts/code-rules.mjs +288 -288
- package/harness/.opencode/scripts/harness-doctor.mjs +52 -0
- package/harness/.opencode/scripts/harness-doctor.test.mjs +58 -0
- package/harness/.opencode/scripts/platform-contract.mjs +366 -366
- package/harness/.opencode/scripts/sync-agents.mjs +29 -0
- package/harness/AGENTS.md +55 -55
- package/harness/ARCHITECTURE.md +13 -13
- package/harness/DESIGN.md +13 -13
- package/harness/opencode.jsonc +36 -36
- package/harness/openspec/config.yaml +20 -20
- package/harness/skills-lock.json +17 -17
- package/package.json +14 -12
- package/skills/README.md +18 -18
- package/skills/agent-harness-cli/SKILL.md +86 -86
- package/skills/browser-automation/SKILL.md +100 -72
- package/skills/pc-guardrails-generic/SKILL.md +53 -53
- package/skills/pc-guardrails-project/SKILL.md +8 -8
- package/skills/pc-make-architecture/SKILL.md +33 -33
- package/skills/pc-make-architecture/structure-template.md +64 -64
- package/skills/pc-make-design/SKILL.md +45 -45
- package/skills/pc-make-engineer/SKILL.md +59 -59
- package/skills/pc-make-engineer/signal-mapping.md +53 -53
- package/skills/pc-make-engineer/template.md +36 -36
- package/skills/pc-make-guardrails/SKILL.md +44 -44
- package/skills/pc-make-guardrails/category-reference.md +112 -112
- package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
- package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
- package/skills/pc-make-user-model/SKILL.md +56 -56
- package/skills/pc-ops-ship/SKILL.md +22 -22
- package/skills/pc-plan-apply/SKILL.md +90 -90
- package/skills/pc-plan-apply/simple-mode.md +21 -21
- package/skills/pc-plan-archive/SKILL.md +66 -66
- package/skills/pc-plan-explore/SKILL.md +21 -21
- package/skills/pc-plan-goal/SKILL.md +91 -91
- package/skills/pc-plan-goal/branching.md +39 -39
- package/skills/pc-plan-goal/failure-policy.md +30 -30
- package/skills/pc-plan-goal/output-mode.md +10 -10
- package/skills/pc-plan-goal/output.md +71 -71
- package/skills/pc-plan-propose/SKILL.md +127 -127
- package/skills/pc-plan-propose/task-annotation.md +39 -39
- package/skills/pc-plan-quick/SKILL.md +46 -46
- package/skills/pc-plan-story/SKILL.md +93 -93
- package/skills/pc-repo-audit/SKILL.md +44 -44
- package/skills/pc-repo-help/SKILL.md +87 -87
- package/skills/pc-repo-initialize/SKILL.md +119 -119
- package/skills/pc-repo-onboard/SKILL.md +32 -32
- package/skills/pc-repo-verify/SKILL.md +150 -150
- package/skills/pc-userstory-az/SKILL.md +71 -71
- package/skills/pc-userstory-browser/SKILL.md +73 -64
- package/skills/pc-userstory-gh/SKILL.md +63 -63
- package/skills/pc-userstory-jira/SKILL.md +74 -74
|
@@ -1,366 +1,366 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
|
|
5
|
-
export const CONTRACT_FILE = '.platform/contract.yml'
|
|
6
|
-
const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml'
|
|
7
|
-
const LEGACY_ANSWERS = '.copier-answers.yml'
|
|
8
|
-
const LEGACY_MANIFEST = '.platform/manifest'
|
|
9
|
-
|
|
10
|
-
export class ContractError extends Error {}
|
|
11
|
-
|
|
12
|
-
function bracket(segment, start) {
|
|
13
|
-
const end = segment.indexOf(']', start + 2)
|
|
14
|
-
if (end === -1) return null
|
|
15
|
-
let body = segment.slice(start + 1, end)
|
|
16
|
-
const negated = body[0] === '!' || body[0] === '^'
|
|
17
|
-
if (negated) body = body.slice(1)
|
|
18
|
-
const ranges = []
|
|
19
|
-
for (let i = 0; i < body.length; i++) {
|
|
20
|
-
const from = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
|
|
21
|
-
let to = from
|
|
22
|
-
if (body[i + 1] === '-' && i + 2 < body.length) {
|
|
23
|
-
i += 2
|
|
24
|
-
to = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
|
|
25
|
-
}
|
|
26
|
-
ranges.push([from, to])
|
|
27
|
-
}
|
|
28
|
-
return { token: { negated, ranges }, end }
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const STAR = Symbol('*')
|
|
32
|
-
|
|
33
|
-
function segmentTokens(segment) {
|
|
34
|
-
const tokens = []
|
|
35
|
-
for (let i = 0; i < segment.length; i++) {
|
|
36
|
-
const c = segment[i]
|
|
37
|
-
const set = c === '[' ? bracket(segment, i) : null
|
|
38
|
-
if (c === '*') {
|
|
39
|
-
if (tokens.at(-1) !== STAR) tokens.push(STAR)
|
|
40
|
-
} else if (c === '?') tokens.push({ negated: true, ranges: [] })
|
|
41
|
-
else if (set) {
|
|
42
|
-
tokens.push(set.token)
|
|
43
|
-
i = set.end
|
|
44
|
-
} else tokens.push(c === '\\' && i + 1 < segment.length ? segment[++i] : c)
|
|
45
|
-
}
|
|
46
|
-
return tokens
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const tokenMatches = (token, c) => typeof token === 'string'
|
|
50
|
-
? token === c
|
|
51
|
-
: token.ranges.some(([from, to]) => from <= c && c <= to) !== token.negated
|
|
52
|
-
|
|
53
|
-
function matchSegment(tokens, text) {
|
|
54
|
-
let t = 0
|
|
55
|
-
let i = 0
|
|
56
|
-
let star = -1
|
|
57
|
-
let resume = 0
|
|
58
|
-
while (i < text.length) {
|
|
59
|
-
if (tokens[t] === STAR) {
|
|
60
|
-
star = t++
|
|
61
|
-
resume = i
|
|
62
|
-
} else if (t < tokens.length && tokenMatches(tokens[t], text[i])) {
|
|
63
|
-
t++
|
|
64
|
-
i++
|
|
65
|
-
} else if (star !== -1) {
|
|
66
|
-
t = star + 1
|
|
67
|
-
i = ++resume
|
|
68
|
-
} else return false
|
|
69
|
-
}
|
|
70
|
-
while (tokens[t] === STAR) t++
|
|
71
|
-
return t === tokens.length
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// gitwildmatch over `/` segments; a RegExp built from contract text is a blocking finding in the apps' Semgrep scan.
|
|
75
|
-
export function compileGlob(glob) {
|
|
76
|
-
const directory = glob.endsWith('/')
|
|
77
|
-
const body = directory ? glob.slice(0, -1) : glob
|
|
78
|
-
const segments = (body.startsWith('/') ? body.slice(1) : body).split('/')
|
|
79
|
-
return { anchored: body.includes('/'), directory, segments: segments.map(s => (s === '**' ? s : segmentTokens(s))) }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function matchFrom(segments, s, parts, p, directory) {
|
|
83
|
-
if (s === segments.length) return parts.length - p >= (directory ? 1 : 0)
|
|
84
|
-
if (segments[s] !== '**') return p < parts.length && matchSegment(segments[s], parts[p]) && matchFrom(segments, s + 1, parts, p + 1, directory)
|
|
85
|
-
for (let next = s + 1 === segments.length ? p + 1 : p; next <= parts.length; next++) {
|
|
86
|
-
if (matchFrom(segments, s + 1, parts, next, directory)) return true
|
|
87
|
-
}
|
|
88
|
-
return false
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function matchGlob(compiled, file) {
|
|
92
|
-
const { anchored, directory, segments } = typeof compiled === 'string' ? compileGlob(compiled) : compiled
|
|
93
|
-
const parts = file.split('/')
|
|
94
|
-
for (let start = 0; start < (anchored ? 1 : parts.length); start++) {
|
|
95
|
-
if (matchFrom(segments, 0, parts, start, directory)) return true
|
|
96
|
-
}
|
|
97
|
-
return false
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const toRules = globs => globs.map(glob => {
|
|
101
|
-
const negated = glob.startsWith('!')
|
|
102
|
-
return { negated, glob: compileGlob(negated ? glob.slice(1) : glob) }
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
function matchesRules(rules, file) {
|
|
106
|
-
let matched = false
|
|
107
|
-
for (const rule of rules) if (matchGlob(rule.glob, file)) matched = !rule.negated
|
|
108
|
-
return matched
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function stripComment(line) {
|
|
112
|
-
let quote = null
|
|
113
|
-
for (let i = 0; i < line.length; i++) {
|
|
114
|
-
const c = line[i]
|
|
115
|
-
if (quote) quote = c === quote ? null : quote
|
|
116
|
-
else if ((c === '"' || c === "'") && (i === 0 || /[\s[,]/.test(line[i - 1]))) quote = c
|
|
117
|
-
else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
|
|
118
|
-
}
|
|
119
|
-
return line
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function scalar(raw) {
|
|
123
|
-
const value = raw.trim()
|
|
124
|
-
const quoted = value.match(/^(["'])(.*)\1$/)
|
|
125
|
-
if (quoted) return quoted[2]
|
|
126
|
-
if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1).split(',').map(scalar).filter(item => item !== '')
|
|
127
|
-
return /^-?\d+$/.test(value) ? Number(value) : value
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const isItem = text => text === '-' || text.startsWith('- ')
|
|
131
|
-
|
|
132
|
-
// The subset of YAML a contract and its exceptions use: nested mappings, block and flow lists, scalars.
|
|
133
|
-
export function parseYaml(text, file) {
|
|
134
|
-
const lines = []
|
|
135
|
-
text.replace(/^\uFEFF/, '').split(/\r?\n/).forEach((raw, index) => {
|
|
136
|
-
const body = stripComment(raw).trimEnd()
|
|
137
|
-
if (body.trim()) lines.push({ indent: body.length - body.trimStart().length, text: body.trim(), number: index + 1 })
|
|
138
|
-
})
|
|
139
|
-
const fail = line => {
|
|
140
|
-
throw new ContractError(`${file}: line ${line.number} cannot be read: \`${line.text}\`.`)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function block(start, indent) {
|
|
144
|
-
const list = isItem(lines[start].text)
|
|
145
|
-
const value = list ? [] : {}
|
|
146
|
-
let i = start
|
|
147
|
-
while (i < lines.length && lines[i].indent === indent) {
|
|
148
|
-
const line = lines[i]
|
|
149
|
-
if (isItem(line.text) !== list) {
|
|
150
|
-
if (list) break
|
|
151
|
-
fail(line)
|
|
152
|
-
}
|
|
153
|
-
if (list) {
|
|
154
|
-
const rest = line.text.slice(1).trimStart()
|
|
155
|
-
if (/^[^"'[{][^:]*:(\s|$)/.test(rest)) {
|
|
156
|
-
lines[i] = { ...line, indent: indent + line.text.length - rest.length, text: rest }
|
|
157
|
-
} else {
|
|
158
|
-
i++
|
|
159
|
-
if (rest || !(i < lines.length && lines[i].indent > indent)) {
|
|
160
|
-
value.push(rest ? scalar(rest) : null)
|
|
161
|
-
continue
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
const [item, after] = block(i, lines[i].indent)
|
|
165
|
-
value.push(item)
|
|
166
|
-
i = after
|
|
167
|
-
continue
|
|
168
|
-
}
|
|
169
|
-
const pair = line.text.match(/^([^:]+?)\s*:(?:\s+(.*))?$/)
|
|
170
|
-
if (!pair) fail(line)
|
|
171
|
-
const key = scalar(pair[1])
|
|
172
|
-
i++
|
|
173
|
-
const next = lines[i]
|
|
174
|
-
if (pair[2]) value[key] = scalar(pair[2])
|
|
175
|
-
else if (next && (next.indent > indent || (next.indent === indent && isItem(next.text)))) [value[key], i] = block(i, next.indent)
|
|
176
|
-
else value[key] = null
|
|
177
|
-
}
|
|
178
|
-
if (i < lines.length && lines[i].indent > indent) fail(lines[i])
|
|
179
|
-
return [value, i]
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (!lines.length) return null
|
|
183
|
-
const [value, end] = block(0, lines[0].indent)
|
|
184
|
-
if (end < lines.length) fail(lines[end])
|
|
185
|
-
return value
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const isText = value => typeof value === 'string' && value.trim() !== ''
|
|
189
|
-
const isMapping = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
190
|
-
const invalid = (file, field, problem) => new ContractError(`${file}: \`${field}\` ${problem}`)
|
|
191
|
-
|
|
192
|
-
function mapping(value, file, field, required = false) {
|
|
193
|
-
if (value == null && !required) return {}
|
|
194
|
-
if (!isMapping(value)) throw invalid(file, field, required ? 'is required and must be a mapping.' : 'must be a mapping.')
|
|
195
|
-
return value
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function text(value, file, field, fallback) {
|
|
199
|
-
if (value == null && fallback !== undefined) return fallback
|
|
200
|
-
if (!isText(value)) throw invalid(file, field, fallback === undefined ? 'is required and must be a non-empty string.' : 'must be a non-empty string.')
|
|
201
|
-
return value
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function textList(value, file, field) {
|
|
205
|
-
if (value == null) return []
|
|
206
|
-
const list = Array.isArray(value) ? value : [value]
|
|
207
|
-
if (!list.every(isText)) throw invalid(file, field, 'must be a list of non-empty strings.')
|
|
208
|
-
return list
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function readContract(source, file = CONTRACT_FILE) {
|
|
212
|
-
const raw = parseYaml(source, file)
|
|
213
|
-
if (!isMapping(raw)) throw new ContractError(`${file}: expected a mapping with \`version\`, \`source\` and \`owned\`.`)
|
|
214
|
-
if (raw.version == null) throw invalid(file, 'version', 'is required.')
|
|
215
|
-
if (String(raw.version) !== '1') throw invalid(file, 'version', `is ${raw.version}; this harness reads version 1.`)
|
|
216
|
-
const origin = mapping(raw.source, file, 'source', true)
|
|
217
|
-
const url = text(origin.url, file, 'source.url')
|
|
218
|
-
const owned = textList(raw.owned, file, 'owned')
|
|
219
|
-
if (!owned.length) throw invalid(file, 'owned', 'is required and must list at least one path.')
|
|
220
|
-
const reuse = mapping(raw.reuse, file, 'reuse')
|
|
221
|
-
const sync = mapping(raw.sync, file, 'sync')
|
|
222
|
-
const version = sync.version == null ? null : mapping(sync.version, file, 'sync.version')
|
|
223
|
-
return {
|
|
224
|
-
file,
|
|
225
|
-
source: { name: text(origin.name, file, 'source.name'), url, changes: text(origin.changes, file, 'source.changes', url) },
|
|
226
|
-
owned,
|
|
227
|
-
reuse: { docs: textList(reuse.docs, file, 'reuse.docs'), packages: textList(reuse.packages, file, 'reuse.packages'), names: textList(reuse.names, file, 'reuse.names') },
|
|
228
|
-
exceptions: text(raw.exceptions, file, 'exceptions', DEFAULT_EXCEPTIONS),
|
|
229
|
-
sync: {
|
|
230
|
-
marker: text(sync.marker, file, 'sync.marker', null),
|
|
231
|
-
version: version && { file: text(version.file, file, 'sync.version.file'), key: text(version.key, file, 'sync.version.key') },
|
|
232
|
-
update: text(sync.update, file, 'sync.update', null),
|
|
233
|
-
},
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function readText(file) {
|
|
238
|
-
try {
|
|
239
|
-
return fs.readFileSync(file, 'utf8')
|
|
240
|
-
} catch {
|
|
241
|
-
return null
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// What harness 3.7.0 read before contract.yml existed: Copier answers plus a manifest of owned globs.
|
|
246
|
-
function legacyContract(root) {
|
|
247
|
-
const answers = readText(path.join(root, LEGACY_ANSWERS))
|
|
248
|
-
const manifest = answers == null ? null : readText(path.join(root, LEGACY_MANIFEST))
|
|
249
|
-
if (manifest == null) return null
|
|
250
|
-
const srcLine = answers.match(/^_src_path:(.*)$/m)
|
|
251
|
-
const url = srcLine ? scalar(stripComment(srcLine[1])) || null : null
|
|
252
|
-
const owned = manifest.split(/\r?\n/).map(line => line.replace(/(?<!\\)\s+$/, '')).filter(line => line && !line.startsWith('#'))
|
|
253
|
-
return {
|
|
254
|
-
file: LEGACY_MANIFEST,
|
|
255
|
-
source: { name: url ? url.replace(/\.git$/, '').split(/[\\/:]/).filter(Boolean).pop() : 'the upstream template', url, changes: url },
|
|
256
|
-
owned,
|
|
257
|
-
reuse: { docs: [], packages: [], names: [] },
|
|
258
|
-
exceptions: DEFAULT_EXCEPTIONS,
|
|
259
|
-
sync: { marker: LEGACY_ANSWERS, version: { file: LEGACY_ANSWERS, key: '_commit' }, update: 'copier update --trust --defaults --conflict inline' },
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
export function loadContract(root) {
|
|
264
|
-
const source = readText(path.join(root, CONTRACT_FILE))
|
|
265
|
-
const contract = source == null ? legacyContract(root) : readContract(source)
|
|
266
|
-
if (!contract) return null
|
|
267
|
-
const exceptions = readText(path.join(root, contract.exceptions))
|
|
268
|
-
const entries = exceptions == null ? null : parseYaml(exceptions, contract.exceptions)?.exceptions
|
|
269
|
-
return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function normalizeRepoUrl(url) {
|
|
273
|
-
return String(url ?? '')
|
|
274
|
-
.trim()
|
|
275
|
-
.replace(/^git\+/, '')
|
|
276
|
-
.replace(/^gh:/, 'https://github.com/')
|
|
277
|
-
.replace(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\//, 'https://$1/')
|
|
278
|
-
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
279
|
-
.replace(/^(https?:\/\/)[^@/]+@/, '$1')
|
|
280
|
-
.replace(/^http:\/\//, 'https://')
|
|
281
|
-
.replace(/\/+$/, '')
|
|
282
|
-
.replace(/\.git$/, '')
|
|
283
|
-
.toLowerCase()
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// The template repository carries its own contract, and its owned paths are its own to change.
|
|
287
|
-
export function isTemplateSource(root, contract) {
|
|
288
|
-
if (!contract?.source.url) return false
|
|
289
|
-
let origin
|
|
290
|
-
try {
|
|
291
|
-
origin = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
292
|
-
} catch {
|
|
293
|
-
return false
|
|
294
|
-
}
|
|
295
|
-
return normalizeRepoUrl(origin) !== '' && normalizeRepoUrl(origin) === normalizeRepoUrl(contract.source.url)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const localDate = (now = new Date()) =>
|
|
299
|
-
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
300
|
-
|
|
301
|
-
export function isOwned(contract, file, today = localDate()) {
|
|
302
|
-
const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
303
|
-
if (!matchesRules(contract.rules, relative)) return false
|
|
304
|
-
return !contract.exceptionList.some(e =>
|
|
305
|
-
isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today && matchGlob(e.path, relative))
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function describeOwner(contract) {
|
|
309
|
-
const { name, changes } = contract.source
|
|
310
|
-
return {
|
|
311
|
-
owner: `owned by ${name} (listed in ${contract.file})`,
|
|
312
|
-
fix: `Ask for the change ${changes ? `at ${changes}` : `in ${name}`}, or add a dated exception to ${contract.exceptions}.`,
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export const nameKey = name => name.toLowerCase().replace(/[-_.\s]/g, '')
|
|
317
|
-
|
|
318
|
-
export function declaredExports(declarations) {
|
|
319
|
-
const names = [...declarations.matchAll(/^export\s+(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm)].map(m => m[1])
|
|
320
|
-
for (const list of declarations.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
|
|
321
|
-
for (const item of list[1].split(',')) {
|
|
322
|
-
const name = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()
|
|
323
|
-
if (name && name !== 'default') names.push(name)
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
return names
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function packageNames(directory) {
|
|
330
|
-
let manifest
|
|
331
|
-
try {
|
|
332
|
-
manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
|
|
333
|
-
} catch {
|
|
334
|
-
return []
|
|
335
|
-
}
|
|
336
|
-
const exportsMap = isMapping(manifest.exports) ? manifest.exports : {}
|
|
337
|
-
const subpaths = Object.keys(exportsMap)
|
|
338
|
-
.filter(key => key.startsWith('./') && !key.includes('*') && !/\.[a-z]+$/i.test(key))
|
|
339
|
-
.map(key => path.posix.basename(key))
|
|
340
|
-
const root = exportsMap['.'] ?? exportsMap
|
|
341
|
-
const types = manifest.types ?? manifest.typings ?? (isText(root.types) ? root.types : null)
|
|
342
|
-
const declarations = isText(types) ? readText(path.join(directory, types)) : null
|
|
343
|
-
return [...subpaths, ...(declarations ? declaredExports(declarations) : [])]
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Packages resolve from the nearest node_modules, so a workspace app finds what its own package.json installs.
|
|
347
|
-
export function reusableNames(root, contract, files = []) {
|
|
348
|
-
const names = new Map()
|
|
349
|
-
const add = (name, origin) => {
|
|
350
|
-
const key = nameKey(name)
|
|
351
|
-
if (key && !names.has(key)) names.set(key, { name, origin })
|
|
352
|
-
}
|
|
353
|
-
for (const name of contract.reuse.names) add(name, contract.file)
|
|
354
|
-
const base = path.resolve(root)
|
|
355
|
-
const directories = new Set([base])
|
|
356
|
-
for (const file of files) {
|
|
357
|
-
for (let dir = path.dirname(path.resolve(base, file)); dir.length > base.length && dir.startsWith(base); dir = path.dirname(dir)) directories.add(dir)
|
|
358
|
-
}
|
|
359
|
-
for (const pkg of contract.reuse.packages) {
|
|
360
|
-
for (const dir of directories) {
|
|
361
|
-
const installed = path.join(dir, 'node_modules', pkg)
|
|
362
|
-
if (fs.existsSync(path.join(installed, 'package.json'))) for (const name of packageNames(installed)) add(name, pkg)
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return names
|
|
366
|
-
}
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const CONTRACT_FILE = '.platform/contract.yml'
|
|
6
|
+
const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml'
|
|
7
|
+
const LEGACY_ANSWERS = '.copier-answers.yml'
|
|
8
|
+
const LEGACY_MANIFEST = '.platform/manifest'
|
|
9
|
+
|
|
10
|
+
export class ContractError extends Error {}
|
|
11
|
+
|
|
12
|
+
function bracket(segment, start) {
|
|
13
|
+
const end = segment.indexOf(']', start + 2)
|
|
14
|
+
if (end === -1) return null
|
|
15
|
+
let body = segment.slice(start + 1, end)
|
|
16
|
+
const negated = body[0] === '!' || body[0] === '^'
|
|
17
|
+
if (negated) body = body.slice(1)
|
|
18
|
+
const ranges = []
|
|
19
|
+
for (let i = 0; i < body.length; i++) {
|
|
20
|
+
const from = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
|
|
21
|
+
let to = from
|
|
22
|
+
if (body[i + 1] === '-' && i + 2 < body.length) {
|
|
23
|
+
i += 2
|
|
24
|
+
to = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
|
|
25
|
+
}
|
|
26
|
+
ranges.push([from, to])
|
|
27
|
+
}
|
|
28
|
+
return { token: { negated, ranges }, end }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STAR = Symbol('*')
|
|
32
|
+
|
|
33
|
+
function segmentTokens(segment) {
|
|
34
|
+
const tokens = []
|
|
35
|
+
for (let i = 0; i < segment.length; i++) {
|
|
36
|
+
const c = segment[i]
|
|
37
|
+
const set = c === '[' ? bracket(segment, i) : null
|
|
38
|
+
if (c === '*') {
|
|
39
|
+
if (tokens.at(-1) !== STAR) tokens.push(STAR)
|
|
40
|
+
} else if (c === '?') tokens.push({ negated: true, ranges: [] })
|
|
41
|
+
else if (set) {
|
|
42
|
+
tokens.push(set.token)
|
|
43
|
+
i = set.end
|
|
44
|
+
} else tokens.push(c === '\\' && i + 1 < segment.length ? segment[++i] : c)
|
|
45
|
+
}
|
|
46
|
+
return tokens
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tokenMatches = (token, c) => typeof token === 'string'
|
|
50
|
+
? token === c
|
|
51
|
+
: token.ranges.some(([from, to]) => from <= c && c <= to) !== token.negated
|
|
52
|
+
|
|
53
|
+
function matchSegment(tokens, text) {
|
|
54
|
+
let t = 0
|
|
55
|
+
let i = 0
|
|
56
|
+
let star = -1
|
|
57
|
+
let resume = 0
|
|
58
|
+
while (i < text.length) {
|
|
59
|
+
if (tokens[t] === STAR) {
|
|
60
|
+
star = t++
|
|
61
|
+
resume = i
|
|
62
|
+
} else if (t < tokens.length && tokenMatches(tokens[t], text[i])) {
|
|
63
|
+
t++
|
|
64
|
+
i++
|
|
65
|
+
} else if (star !== -1) {
|
|
66
|
+
t = star + 1
|
|
67
|
+
i = ++resume
|
|
68
|
+
} else return false
|
|
69
|
+
}
|
|
70
|
+
while (tokens[t] === STAR) t++
|
|
71
|
+
return t === tokens.length
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// gitwildmatch over `/` segments; a RegExp built from contract text is a blocking finding in the apps' Semgrep scan.
|
|
75
|
+
export function compileGlob(glob) {
|
|
76
|
+
const directory = glob.endsWith('/')
|
|
77
|
+
const body = directory ? glob.slice(0, -1) : glob
|
|
78
|
+
const segments = (body.startsWith('/') ? body.slice(1) : body).split('/')
|
|
79
|
+
return { anchored: body.includes('/'), directory, segments: segments.map(s => (s === '**' ? s : segmentTokens(s))) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function matchFrom(segments, s, parts, p, directory) {
|
|
83
|
+
if (s === segments.length) return parts.length - p >= (directory ? 1 : 0)
|
|
84
|
+
if (segments[s] !== '**') return p < parts.length && matchSegment(segments[s], parts[p]) && matchFrom(segments, s + 1, parts, p + 1, directory)
|
|
85
|
+
for (let next = s + 1 === segments.length ? p + 1 : p; next <= parts.length; next++) {
|
|
86
|
+
if (matchFrom(segments, s + 1, parts, next, directory)) return true
|
|
87
|
+
}
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function matchGlob(compiled, file) {
|
|
92
|
+
const { anchored, directory, segments } = typeof compiled === 'string' ? compileGlob(compiled) : compiled
|
|
93
|
+
const parts = file.split('/')
|
|
94
|
+
for (let start = 0; start < (anchored ? 1 : parts.length); start++) {
|
|
95
|
+
if (matchFrom(segments, 0, parts, start, directory)) return true
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const toRules = globs => globs.map(glob => {
|
|
101
|
+
const negated = glob.startsWith('!')
|
|
102
|
+
return { negated, glob: compileGlob(negated ? glob.slice(1) : glob) }
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
function matchesRules(rules, file) {
|
|
106
|
+
let matched = false
|
|
107
|
+
for (const rule of rules) if (matchGlob(rule.glob, file)) matched = !rule.negated
|
|
108
|
+
return matched
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stripComment(line) {
|
|
112
|
+
let quote = null
|
|
113
|
+
for (let i = 0; i < line.length; i++) {
|
|
114
|
+
const c = line[i]
|
|
115
|
+
if (quote) quote = c === quote ? null : quote
|
|
116
|
+
else if ((c === '"' || c === "'") && (i === 0 || /[\s[,]/.test(line[i - 1]))) quote = c
|
|
117
|
+
else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
|
|
118
|
+
}
|
|
119
|
+
return line
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scalar(raw) {
|
|
123
|
+
const value = raw.trim()
|
|
124
|
+
const quoted = value.match(/^(["'])(.*)\1$/)
|
|
125
|
+
if (quoted) return quoted[2]
|
|
126
|
+
if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1).split(',').map(scalar).filter(item => item !== '')
|
|
127
|
+
return /^-?\d+$/.test(value) ? Number(value) : value
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isItem = text => text === '-' || text.startsWith('- ')
|
|
131
|
+
|
|
132
|
+
// The subset of YAML a contract and its exceptions use: nested mappings, block and flow lists, scalars.
|
|
133
|
+
export function parseYaml(text, file) {
|
|
134
|
+
const lines = []
|
|
135
|
+
text.replace(/^\uFEFF/, '').split(/\r?\n/).forEach((raw, index) => {
|
|
136
|
+
const body = stripComment(raw).trimEnd()
|
|
137
|
+
if (body.trim()) lines.push({ indent: body.length - body.trimStart().length, text: body.trim(), number: index + 1 })
|
|
138
|
+
})
|
|
139
|
+
const fail = line => {
|
|
140
|
+
throw new ContractError(`${file}: line ${line.number} cannot be read: \`${line.text}\`.`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function block(start, indent) {
|
|
144
|
+
const list = isItem(lines[start].text)
|
|
145
|
+
const value = list ? [] : {}
|
|
146
|
+
let i = start
|
|
147
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
148
|
+
const line = lines[i]
|
|
149
|
+
if (isItem(line.text) !== list) {
|
|
150
|
+
if (list) break
|
|
151
|
+
fail(line)
|
|
152
|
+
}
|
|
153
|
+
if (list) {
|
|
154
|
+
const rest = line.text.slice(1).trimStart()
|
|
155
|
+
if (/^[^"'[{][^:]*:(\s|$)/.test(rest)) {
|
|
156
|
+
lines[i] = { ...line, indent: indent + line.text.length - rest.length, text: rest }
|
|
157
|
+
} else {
|
|
158
|
+
i++
|
|
159
|
+
if (rest || !(i < lines.length && lines[i].indent > indent)) {
|
|
160
|
+
value.push(rest ? scalar(rest) : null)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const [item, after] = block(i, lines[i].indent)
|
|
165
|
+
value.push(item)
|
|
166
|
+
i = after
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
const pair = line.text.match(/^([^:]+?)\s*:(?:\s+(.*))?$/)
|
|
170
|
+
if (!pair) fail(line)
|
|
171
|
+
const key = scalar(pair[1])
|
|
172
|
+
i++
|
|
173
|
+
const next = lines[i]
|
|
174
|
+
if (pair[2]) value[key] = scalar(pair[2])
|
|
175
|
+
else if (next && (next.indent > indent || (next.indent === indent && isItem(next.text)))) [value[key], i] = block(i, next.indent)
|
|
176
|
+
else value[key] = null
|
|
177
|
+
}
|
|
178
|
+
if (i < lines.length && lines[i].indent > indent) fail(lines[i])
|
|
179
|
+
return [value, i]
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!lines.length) return null
|
|
183
|
+
const [value, end] = block(0, lines[0].indent)
|
|
184
|
+
if (end < lines.length) fail(lines[end])
|
|
185
|
+
return value
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const isText = value => typeof value === 'string' && value.trim() !== ''
|
|
189
|
+
const isMapping = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
190
|
+
const invalid = (file, field, problem) => new ContractError(`${file}: \`${field}\` ${problem}`)
|
|
191
|
+
|
|
192
|
+
function mapping(value, file, field, required = false) {
|
|
193
|
+
if (value == null && !required) return {}
|
|
194
|
+
if (!isMapping(value)) throw invalid(file, field, required ? 'is required and must be a mapping.' : 'must be a mapping.')
|
|
195
|
+
return value
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function text(value, file, field, fallback) {
|
|
199
|
+
if (value == null && fallback !== undefined) return fallback
|
|
200
|
+
if (!isText(value)) throw invalid(file, field, fallback === undefined ? 'is required and must be a non-empty string.' : 'must be a non-empty string.')
|
|
201
|
+
return value
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function textList(value, file, field) {
|
|
205
|
+
if (value == null) return []
|
|
206
|
+
const list = Array.isArray(value) ? value : [value]
|
|
207
|
+
if (!list.every(isText)) throw invalid(file, field, 'must be a list of non-empty strings.')
|
|
208
|
+
return list
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function readContract(source, file = CONTRACT_FILE) {
|
|
212
|
+
const raw = parseYaml(source, file)
|
|
213
|
+
if (!isMapping(raw)) throw new ContractError(`${file}: expected a mapping with \`version\`, \`source\` and \`owned\`.`)
|
|
214
|
+
if (raw.version == null) throw invalid(file, 'version', 'is required.')
|
|
215
|
+
if (String(raw.version) !== '1') throw invalid(file, 'version', `is ${raw.version}; this harness reads version 1.`)
|
|
216
|
+
const origin = mapping(raw.source, file, 'source', true)
|
|
217
|
+
const url = text(origin.url, file, 'source.url')
|
|
218
|
+
const owned = textList(raw.owned, file, 'owned')
|
|
219
|
+
if (!owned.length) throw invalid(file, 'owned', 'is required and must list at least one path.')
|
|
220
|
+
const reuse = mapping(raw.reuse, file, 'reuse')
|
|
221
|
+
const sync = mapping(raw.sync, file, 'sync')
|
|
222
|
+
const version = sync.version == null ? null : mapping(sync.version, file, 'sync.version')
|
|
223
|
+
return {
|
|
224
|
+
file,
|
|
225
|
+
source: { name: text(origin.name, file, 'source.name'), url, changes: text(origin.changes, file, 'source.changes', url) },
|
|
226
|
+
owned,
|
|
227
|
+
reuse: { docs: textList(reuse.docs, file, 'reuse.docs'), packages: textList(reuse.packages, file, 'reuse.packages'), names: textList(reuse.names, file, 'reuse.names') },
|
|
228
|
+
exceptions: text(raw.exceptions, file, 'exceptions', DEFAULT_EXCEPTIONS),
|
|
229
|
+
sync: {
|
|
230
|
+
marker: text(sync.marker, file, 'sync.marker', null),
|
|
231
|
+
version: version && { file: text(version.file, file, 'sync.version.file'), key: text(version.key, file, 'sync.version.key') },
|
|
232
|
+
update: text(sync.update, file, 'sync.update', null),
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readText(file) {
|
|
238
|
+
try {
|
|
239
|
+
return fs.readFileSync(file, 'utf8')
|
|
240
|
+
} catch {
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// What harness 3.7.0 read before contract.yml existed: Copier answers plus a manifest of owned globs.
|
|
246
|
+
function legacyContract(root) {
|
|
247
|
+
const answers = readText(path.join(root, LEGACY_ANSWERS))
|
|
248
|
+
const manifest = answers == null ? null : readText(path.join(root, LEGACY_MANIFEST))
|
|
249
|
+
if (manifest == null) return null
|
|
250
|
+
const srcLine = answers.match(/^_src_path:(.*)$/m)
|
|
251
|
+
const url = srcLine ? scalar(stripComment(srcLine[1])) || null : null
|
|
252
|
+
const owned = manifest.split(/\r?\n/).map(line => line.replace(/(?<!\\)\s+$/, '')).filter(line => line && !line.startsWith('#'))
|
|
253
|
+
return {
|
|
254
|
+
file: LEGACY_MANIFEST,
|
|
255
|
+
source: { name: url ? url.replace(/\.git$/, '').split(/[\\/:]/).filter(Boolean).pop() : 'the upstream template', url, changes: url },
|
|
256
|
+
owned,
|
|
257
|
+
reuse: { docs: [], packages: [], names: [] },
|
|
258
|
+
exceptions: DEFAULT_EXCEPTIONS,
|
|
259
|
+
sync: { marker: LEGACY_ANSWERS, version: { file: LEGACY_ANSWERS, key: '_commit' }, update: 'copier update --trust --defaults --conflict inline' },
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function loadContract(root) {
|
|
264
|
+
const source = readText(path.join(root, CONTRACT_FILE))
|
|
265
|
+
const contract = source == null ? legacyContract(root) : readContract(source)
|
|
266
|
+
if (!contract) return null
|
|
267
|
+
const exceptions = readText(path.join(root, contract.exceptions))
|
|
268
|
+
const entries = exceptions == null ? null : parseYaml(exceptions, contract.exceptions)?.exceptions
|
|
269
|
+
return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function normalizeRepoUrl(url) {
|
|
273
|
+
return String(url ?? '')
|
|
274
|
+
.trim()
|
|
275
|
+
.replace(/^git\+/, '')
|
|
276
|
+
.replace(/^gh:/, 'https://github.com/')
|
|
277
|
+
.replace(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\//, 'https://$1/')
|
|
278
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
279
|
+
.replace(/^(https?:\/\/)[^@/]+@/, '$1')
|
|
280
|
+
.replace(/^http:\/\//, 'https://')
|
|
281
|
+
.replace(/\/+$/, '')
|
|
282
|
+
.replace(/\.git$/, '')
|
|
283
|
+
.toLowerCase()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The template repository carries its own contract, and its owned paths are its own to change.
|
|
287
|
+
export function isTemplateSource(root, contract) {
|
|
288
|
+
if (!contract?.source.url) return false
|
|
289
|
+
let origin
|
|
290
|
+
try {
|
|
291
|
+
origin = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
292
|
+
} catch {
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
return normalizeRepoUrl(origin) !== '' && normalizeRepoUrl(origin) === normalizeRepoUrl(contract.source.url)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const localDate = (now = new Date()) =>
|
|
299
|
+
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
300
|
+
|
|
301
|
+
export function isOwned(contract, file, today = localDate()) {
|
|
302
|
+
const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
303
|
+
if (!matchesRules(contract.rules, relative)) return false
|
|
304
|
+
return !contract.exceptionList.some(e =>
|
|
305
|
+
isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today && matchGlob(e.path, relative))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function describeOwner(contract) {
|
|
309
|
+
const { name, changes } = contract.source
|
|
310
|
+
return {
|
|
311
|
+
owner: `owned by ${name} (listed in ${contract.file})`,
|
|
312
|
+
fix: `Ask for the change ${changes ? `at ${changes}` : `in ${name}`}, or add a dated exception to ${contract.exceptions}.`,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const nameKey = name => name.toLowerCase().replace(/[-_.\s]/g, '')
|
|
317
|
+
|
|
318
|
+
export function declaredExports(declarations) {
|
|
319
|
+
const names = [...declarations.matchAll(/^export\s+(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm)].map(m => m[1])
|
|
320
|
+
for (const list of declarations.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
|
|
321
|
+
for (const item of list[1].split(',')) {
|
|
322
|
+
const name = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()
|
|
323
|
+
if (name && name !== 'default') names.push(name)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return names
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function packageNames(directory) {
|
|
330
|
+
let manifest
|
|
331
|
+
try {
|
|
332
|
+
manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
|
|
333
|
+
} catch {
|
|
334
|
+
return []
|
|
335
|
+
}
|
|
336
|
+
const exportsMap = isMapping(manifest.exports) ? manifest.exports : {}
|
|
337
|
+
const subpaths = Object.keys(exportsMap)
|
|
338
|
+
.filter(key => key.startsWith('./') && !key.includes('*') && !/\.[a-z]+$/i.test(key))
|
|
339
|
+
.map(key => path.posix.basename(key))
|
|
340
|
+
const root = exportsMap['.'] ?? exportsMap
|
|
341
|
+
const types = manifest.types ?? manifest.typings ?? (isText(root.types) ? root.types : null)
|
|
342
|
+
const declarations = isText(types) ? readText(path.join(directory, types)) : null
|
|
343
|
+
return [...subpaths, ...(declarations ? declaredExports(declarations) : [])]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Packages resolve from the nearest node_modules, so a workspace app finds what its own package.json installs.
|
|
347
|
+
export function reusableNames(root, contract, files = []) {
|
|
348
|
+
const names = new Map()
|
|
349
|
+
const add = (name, origin) => {
|
|
350
|
+
const key = nameKey(name)
|
|
351
|
+
if (key && !names.has(key)) names.set(key, { name, origin })
|
|
352
|
+
}
|
|
353
|
+
for (const name of contract.reuse.names) add(name, contract.file)
|
|
354
|
+
const base = path.resolve(root)
|
|
355
|
+
const directories = new Set([base])
|
|
356
|
+
for (const file of files) {
|
|
357
|
+
for (let dir = path.dirname(path.resolve(base, file)); dir.length > base.length && dir.startsWith(base); dir = path.dirname(dir)) directories.add(dir)
|
|
358
|
+
}
|
|
359
|
+
for (const pkg of contract.reuse.packages) {
|
|
360
|
+
for (const dir of directories) {
|
|
361
|
+
const installed = path.join(dir, 'node_modules', pkg)
|
|
362
|
+
if (fs.existsSync(path.join(installed, 'package.json'))) for (const name of packageNames(installed)) add(name, pkg)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return names
|
|
366
|
+
}
|